Android原生下载(上篇)基本逻辑+断点续传

零、前言

1.今天带来的是Android原生下载的上篇,主要核心是断点续传,多线程下载将会在下篇介绍 2.本例使用了ActivityServiceBroadcastReceiver三个组件 3.本例使用了两个线程:LinkURLThread做一些初始工作,DownLoadThread进行核心下载工作 4.本例使用SQLite进行暂停时的进度保存,使用Handler进行消息的传递,使用Intent进行数据传递 5.对着代码,整理了一下思路,画了一幅下面的流程图,感觉思路清晰多了 6.本例比较基础,但串联了Android的很多知识点,作为总结还是很不错的。

2018-11-13更新:

改善了一下界面UI,整个画风都不同了,个人感觉还不错,用了以前的自定义进度条:详见

效果展示.png

断点续传逻辑总览

断点续传逻辑总览.png


一、前置准备工作

先实现上面一半的代码:

初始准备.png

1.关于下载的链接:

既然是下载,当然要有链接了,就那掘金的apk来测试吧!查看方式:

查看下载地址.png

2.文件信息封装类:FileBean
public class FileBean implements Serializable {
    private int id;//文件id
    private String url;//文件下载地址
    private String fileName;//文件名
    private long length;//文件长度
    private long loadedLen;//文件已下载长度
    
    //构造函数、get、set、toString省略...
}
2.关于常量:Cons.java

无论是Intent添加的Action,还是Intent传递数据的标示,或Handler发送消息的标示 一个项目中肯定会有很多这样的常量,如果散落各处感觉会很乱,我习惯使用一个Cons类统一处理

//intent传递数据----开始下载时,传递FileBean到Service 标示
public static final String SEND_FILE_BEAN = "send_file_bean";
//广播更新进度
public static final String SEND_LOADED_PROGRESS = "send_loaded_length";

//下载地址
public static final String URL = "https://imtt.dd.qq.com/16891/4611E43165D203CB6A52E65759FE7641.apk?fsname=com.daimajia.gold_5.6.2_196.apk&csr=1bbd";

//文件下载路径
public static final String DOWNLOAD_DIR =
        Environment.getExternalStorageDirectory().getAbsolutePath() + "/b_download/";

//Handler的Message处理的常量
public static final int MSG_CREATE_FILE_OK = 0x00;
2.Activity与Service的协作

界面比较简单,就不贴了

效果.png

1).Activity中:
/**
 * 点击下载时逻辑
 */
private void start() {
    //创建FileBean对象
    FileBean fileBean = new FileBean(0, Cons.URL, "掘金.apk", 0, 0);
    Intent intent = new Intent(MainActivity.this, DownLoadService.class);
    intent.setAction(Cons.ACTION_START);
    intent.putExtra(Cons.SEND_FILE_BEAN, fileBean);//使用intent携带对象
    startService(intent);//开启服务--下载标示
    mIdTvFileName.setText(fileBean.getFileName());
}
/**
 * 点击停止下载逻辑
 */
private void stop() {
    Intent intent = new Intent(MainActivity.this, DownLoadService.class);
    intent.setAction(Cons.ACTION_STOP);
    startService(intent);//启动服务---停止标示
}
2).DownLoadService:下载的服务
public class DownLoadService extends Service {
    @Override//每次启动服务会走此方法
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent.getAction() != null) {
            switch (intent.getAction()) {
                case Cons.ACTION_START:
                    FileBean fileBean = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
                    L.d("action_start:" + fileBean + L.l());
                    break;
                case Cons.ACTION_STOP:
                    L.d("action_stop:");
                    break;
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

不要忘记注册Service:<service android:name=".service.DownLoadService"/> 通过点击两个按钮,测试可以看出FileBean对象的传递和下载开始、停止的逻辑没有问题

测试.png


二、下载的初始线程及使用:

1.LinkURLThread线程的实现

1).连接网络文件 2).获取文件长度 3).创建等大的本地文件:RandomAccessFile 4).从mHandler的消息池中拿个消息,附带mFileBean和MSG_CREATE_FILE_OK标示发送给mHandler

/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:13:42<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:连接url做一些准备工作:获取文件大小。创建文件夹及等大的文件
 */
public class LinkURLThread extends Thread {

    private FileBean mFileBean;
    private Handler mHandler;

    public LinkURLThread(FileBean fileBean, Handler handler) {
        mFileBean = fileBean;
        mHandler = handler;
    }

    @Override
    public void run() {
        HttpURLConnection conn = null;
        RandomAccessFile raf = null;
        try {
            //1.连接网络文件
            URL url = new URL(mFileBean.getUrl());
            conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("GET");
            if (conn.getResponseCode() == 200) {
                //2.获取文件长度
                long len = conn.getContentLength();
                if (len > 0) {
                    File dir = new File(Cons.DOWNLOAD_DIR);
                    if (!dir.exists()) {
                        dir.mkdir();
                    }
                    //3.创建等大的本地文件
                    File file = new File(dir, mFileBean.getFileName());
                    //创建随机操作的文件流对象,可读、写、删除
                    raf = new RandomAccessFile(file, "rwd");
                    raf.setLength(len);//设置文件大小
                    mFileBean.setLength(len);
                    //4.从mHandler的消息池中拿个消息,附带mFileBean和MSG_CREATE_FILE_OK标示发送给mHandler
                    mHandler.obtainMessage(Cons.MSG_CREATE_FILE_OK, mFileBean).sendToTarget();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
            try {
                if (raf != null) {
                    raf.close();
                }
            } catch (IOException e) {
                e.printStackTrace();

            }
        }
    }
}
2.在Service中的使用:DownLoadService

由于Service也是运行在主线程的,访问网络的耗时操作是进制的,所以需要新开线程 由于子线程不能更新UI,这里使用传统的Handler进行线程间通信

/**
 * 处理消息使用的Handler
 */
private Handler mHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case Cons.MSG_CREATE_FILE_OK:
                FileBean fileBean = (FileBean) msg.obj;
                //已在主线程,可更新UI
                ToastUtil.showAtOnce(DownLoadService.this, "文件长度:" + fileBean.getLength());
                download(fileBean);
                break;
        }
    }
};

//下载的Action时开启线程:
new LinkURLThread(fileBean, mHandler).start();

可见开启线程后,拿到文件大小,Handler发送消息到Service,再在Service(主线程)进行UI的显示(吐司)

初始连接线程测试.png


三、数据库相关操作:

数据库相关.png

先说一下数据库是干嘛用的:记录下载线程的信息信息信息! 当暂停时,将当前下载的进度及线程信息保存到数据库中,当再点击开始是从数据库查找线程信息,恢复下载

1.线程信息封装类:ThreadBean
private int id;//线程id
private String url;//线程所下载文件的url
private long start;//线程开始的下载位置(为多线程准备)
private long end;//线程结束的下载位置
private long loadedLen;//该线程已下载的长度

//构造函数、get、set、toString省略...
2.下载的数据库帮助类:DownLoadDBHelper

关于SQLite可详见SI--安卓SQLite基础使用指南:

/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:14:19<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:下载的数据库帮助类
 */
public class DownLoadDBHelper extends SQLiteOpenHelper {

    public DownLoadDBHelper(@Nullable Context context) {
        super(context, Cons.DB_NAME, null, Cons.VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(Cons.DB_SQL_CREATE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL(Cons.DB_SQL_DROP);
        db.execSQL(Cons.DB_SQL_CREATE);
    }
}
3.关于数据库的常量:Cons.java
/**
 * 数据库相关常量
 */
public static final String DB_NAME = "download.db";//数据库名
public static final int VERSION = 1;//版本
public static final String DB_TABLE_NAME = "thread_info";//数据库名
public static final String DB_SQL_CREATE = //创建表
        "CREATE TABLE " + DB_TABLE_NAME + "(\n" +
                "_id INTEGER PRIMARY KEY AUTOINCREMENT,\n" +
                "thread_id INTEGER,\n" +
                "url TEXT,\n" +
                "start INTEGER,\n" +
                "end INTEGER,\n" +
                "loadedLen INTEGER\n" +
                ")";
public static final String DB_SQL_DROP =//删除表表
        "DROP TABLE IF EXISTS " + DB_TABLE_NAME;
public static final String DB_SQL_INSERT =//插入
        "INSERT INTO " + DB_TABLE_NAME + " (thread_id,url,start,end,loadedLen) values(?,?,?,?,?)";
public static final String DB_SQL_DELETE =//删除
        "DELETE FROM " + DB_TABLE_NAME + " WHERE url = ? AND thread_id = ?";
public static final String DB_SQL_UPDATE =//更新
        "UPDATE " + DB_TABLE_NAME + " SET loadedLen = ? WHERE url = ? AND thread_id = ?";
public static final String DB_SQL_FIND =//查询
        "SELECT * FROM " + DB_TABLE_NAME + " WHERE url = ?";
public static final String DB_SQL_FIND_IS_EXISTS =//查询是否存在
        "SELECT * FROM " + DB_TABLE_NAME + " WHERE url = ? AND thread_id = ?";
4.数据访问接口:DownLoadDao

提供数据库操作的接口 ,至于为什么要一个dao的接口,直接用实现类不行吗,这里重点说一下

接口体现的是一种能力保证,实现类的对象是具有这种能力的对象之一。
如果你非常确定这种实现不会改变(即这里确定一种用SQLite),直接使用实现类当然可以。
不过如果你不想存入数据库了,而是存在文件里或SP里,那所有与实现类相关的部分都要修改,如果散布各个地方,还不崩溃。
使用接口的好处在于,不管你黑猫白狗(实现方案),帮我抓住耗子(解决问题)就行了。
所以你完全可以写一套在文件里储存线程信息的方案,然后实现dao里的方法,
再只要更换代码中的dao实现就可以轻松地将黑猫(数据库实现)切换成白狗(文件操作实现),
当然你也可以准备一头猫头鹰(SP实现),或一门灭鼠大炮(网络流实现),这样就让下载逻辑和存储逻辑解耦  
你想上午让白狗(文件操作实现)抓老鼠,下午让白猫(数据库实现),晚上让猫头鹰(SP实现),都不是问题  
这就是面相接口编程的好处,如果你遇到类似的情形,很多实现都各有优劣,你完全可以面相接口,后期再根据不同的需求写实现
/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:14:36<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:数据访问接口
 */
public interface DownLoadDao {
    /**
     * 在数据库插入线程信息
     *
     * @param threadBean 线程信息
     */
    void insertThread(ThreadBean threadBean);

    /**
     * 在数据库删除线程信息
     *
     * @param url      下载的url
     * @param threadId 线程的id
     */
    void deleteThread(String url, int threadId);

    /**
     * 在数据库更新线程信息---下载进度
     *
     * @param url      下载的url
     * @param threadId 线程的id
     */
    void updateThread(String url, int threadId ,long loadedLen);

    /**
     * 获取一个文件下载的所有线程信息(多线程下载)
     * @param url 下载的url
     * @return  线程信息集合
     */
    List<ThreadBean> getThreads(String url);

    /**
     * 判断数据库中该线程信息是否存在
     *
     * @param url      下载的url
     * @param threadId 线程的id
     */
    boolean isExist(String url, int threadId);
}
5.数据库接口实现类:DownLoadDaoImpl

一些基础的SQL操作,个人习惯原生的SQL,在每次操作之后不要忘记关闭db,以及游标

/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:14:43<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:数据访问接口实现类
 */
public class DownLoadDaoImpl implements DownLoadDao {

    private DownLoadDBHelper mDBHelper;
    private Context mContext;

    public DownLoadDaoImpl(Context context) {
        mContext = context;
        mDBHelper = new DownLoadDBHelper(mContext);
    }

    @Override
    public void insertThread(ThreadBean threadBean) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_INSERT,
                new Object[]{threadBean.getId(), threadBean.getUrl(),
                        threadBean.getStart(), threadBean.getEnd(), threadBean.getLoadedLen()});
        db.close();
    }

    @Override
    public void deleteThread(String url, int threadId) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_DELETE,
                new Object[]{url, threadId});
        db.close();
    }

    @Override
    public void updateThread(String url, int threadId, long loadedLen) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        db.execSQL(Cons.DB_SQL_UPDATE,
                new Object[]{loadedLen, url, threadId});
        db.close();
    }

    @Override
    public List<ThreadBean> getThreads(String url) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        Cursor cursor = db.rawQuery(Cons.DB_SQL_FIND, new String[]{url});
        List<ThreadBean> threadBeans = new ArrayList<>();
        while (cursor.moveToNext()) {
            ThreadBean threadBean = new ThreadBean();
            threadBean.setId(cursor.getInt(cursor.getColumnIndex("thread_id")));
            threadBean.setUrl(cursor.getString(cursor.getColumnIndex("url")));
            threadBean.setStart(cursor.getLong(cursor.getColumnIndex("start")));
            threadBean.setEnd(cursor.getLong(cursor.getColumnIndex("end")));
            threadBean.setLoadedLen(cursor.getLong(cursor.getColumnIndex("loadedLen")));
            threadBeans.add(threadBean);
        }
        cursor.close();
        db.close();
        return threadBeans;
    }

    @Override
    public boolean isExist(String url, int threadId) {
        SQLiteDatabase db = mDBHelper.getWritableDatabase();
        Cursor cursor = db.rawQuery(Cons.DB_SQL_FIND_IS_EXISTS, new String[]{url, threadId + ""});
        boolean exists = cursor.moveToNext();
        cursor.close();
        db.close();

        return exists;
    }
}

四、核心下载线程:DownLoadThread 与进度广播:BroadcastReceiver

下载核心线程.png

1.下载线程:

注意请求中使用Range后,服务器返回的成功状态码是206:不是200,表示:部分内容和范围请求成功 注释写的很详细了,就不赘述了

/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:15:10<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:下载线程
 */
public class DownLoadThread extends Thread {

    private ThreadBean mThreadBean;//下载线程的信息
    private FileBean mFileBean;//下载文件的信息
    private long mLoadedLen;//已下载的长度
    public boolean isDownLoading;//是否在下载
    private DownLoadDao mDao;//数据访问接口
    private Context mContext;//上下文

    public DownLoadThread(ThreadBean threadBean, FileBean fileBean, Context context) {
        mThreadBean = threadBean;
        mDao = new DownLoadDaoImpl(context);
        mFileBean = fileBean;
        mContext = context;
    }

    @Override
    public void run() {
        if (mThreadBean == null) {//1.下载线程的信息为空,直接返回
            return;
        }
        //2.如果数据库没有此下载线程的信息,则向数据库插入该线程信息
        if (!mDao.isExist(mThreadBean.getUrl(), mThreadBean.getId())) {
            mDao.insertThread(mThreadBean);
        }

        HttpURLConnection conn = null;
        RandomAccessFile raf = null;
        InputStream is = null;
        try {
            //3.连接线程的url
            URL url = new URL(mThreadBean.getUrl());
            conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(5000);
            conn.setRequestMethod("GET");
            //4.设置下载位置
            long start = mThreadBean.getStart() + mThreadBean.getLoadedLen();//开始位置
            //conn设置属性,标记资源的位置(这是给服务器看的)
            conn.setRequestProperty("Range", "bytes=" + start + "-" + mThreadBean.getEnd());
            //5.寻找文件的写入位置
            File file = new File(Cons.DOWNLOAD_DIR, mFileBean.getFileName());
            //创建随机操作的文件流对象,可读、写、删除
            raf = new RandomAccessFile(file, "rwd");
            raf.seek(start);//设置文件写入位置
            //6.下载的核心逻辑
            Intent intent = new Intent(Cons.ACTION_UPDATE);//更新进度的广播intent
            mLoadedLen += mThreadBean.getLoadedLen();
            //206-----部分内容和范围请求  不要200写顺手了...
            if (conn.getResponseCode() == 206) {
                //读取数据
                is = conn.getInputStream();
                byte[] buf = new byte[1024 * 4];
                int len = 0;
                long time = System.currentTimeMillis();
                while ((len = is.read(buf)) != -1) {
                    //写入文件
                    raf.write(buf, 0, len);
                    //发送广播给Activity,通知进度
                    mLoadedLen += len;
                    if (System.currentTimeMillis() - time > 500) {//减少UI的渲染速度
                        mContext.sendBroadcast(intent);
                        intent.putExtra(Cons.SEND_LOADED_PROGRESS,
                                (int) (mLoadedLen * 100 / mFileBean.getLength()));
                        mContext.sendBroadcast(intent);
                        time = System.currentTimeMillis();
                    }
                    //暂停保存进度到数据库
                    if (!isDownLoading) {
                        mDao.updateThread(mThreadBean.getUrl(), mThreadBean.getId(), mLoadedLen);
                        return;
                    }
                }
            }
            //下载完成,删除线程信息
            mDao.deleteThread(mThreadBean.getUrl(), mThreadBean.getId());
            //下载完成后,发送完成度100%的广播
            intent.putExtra(Cons.SEND_LOADED_PROGRESS, 100);
            mContext.sendBroadcast(intent);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
            try {
                if (raf != null) {
                    raf.close();
                }
                if (is != null) {
                    is.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
3.进度广播:BroadcastReceiver

注意这里并非只能用BroadcastReceiver,任何线程间通信都可以,只是将进度从下载线程拿过来而已

/**
 * 作者:张风捷特烈<br/>
 * 时间:2018/11/12 0012:16:05<br/>
 * 邮箱:1981462002@qq.com<br/>
 * 说明:更新ui的广播接收者
 */
public class UpdateReceiver extends BroadcastReceiver {
    private ProgressBar[] mProgressBar;

    public UpdateReceiver(ProgressBar... progressBar) {
        mProgressBar = progressBar;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        if (Cons.ACTION_UPDATE.equals(intent.getAction())) {

            int progress = intent.getIntExtra(Cons.SEND_LOADED_PROGRESS, 0);
            for (ProgressBar progressBar : mProgressBar) {
                progressBar.setProgress(progress);

            }
        }
    }
}

五、将两大部分拼合一起

1.DownLoadService:下载服务

在接收到Handler的信息后调用下载函数

/**
 * 下载逻辑
 *
 * @param fileBean 文件信息对象
 */
public void download(FileBean fileBean) {
    //从数据获取线程信息
    List<ThreadBean> threads = mDao.getThreads(fileBean.getUrl());
    if (threads.size() == 0) {//如果没有线程信息,就新建线程信息
        mThreadBean = new ThreadBean(
                0, fileBean.getUrl(), 0, fileBean.getLength(), 0);//初始化线程信息对象
    } else {
        mThreadBean = threads.get(0);//否则取第一个
    }
    mDownLoadThread = new DownLoadThread(mThreadBean, fileBean, this);//创建下载线程
    mDownLoadThread.start();//开始线程
    mDownLoadThread.isDownLoading = true;
}
2.开始与停止下载的优化:
@Override//每次启动服务会走此方法
public int onStartCommand(Intent intent, int flags, int startId) {
    mDao = new DownLoadDaoImpl(this);
    if (intent.getAction() != null) {
        switch (intent.getAction()) {
            case Cons.ACTION_START:
                FileBean fileBean = (FileBean) intent.getSerializableExtra(Cons.SEND_FILE_BEAN);
                if (mDownLoadThread != null) {
                    if (mDownLoadThread.isDownLoading) {
                        return super.onStartCommand(intent, flags, startId);
                    }
                }
                new LinkURLThread(fileBean, mHandler).start();
                break;
            case Cons.ACTION_STOP:
                if (mDownLoadThread != null) {
                    mDownLoadThread.isDownLoading = false;
                }
                break;
        }
    }
    return super.onStartCommand(intent, flags, startId);
}
3.Activity中注册和注销广播
/**
 * 注册广播接收者
 */
private void register() {
    //注册广播接收者
    mUpdateReceiver = new UpdateReceiver(mProgressBar,mIdRoundPb);
    IntentFilter filter = new IntentFilter();
    filter.addAction(Cons.ACTION_UPDATE);
    registerReceiver(mUpdateReceiver, filter);
}

@Override
protected void onDestroy() {
    super.onDestroy();
    if (mUpdateReceiver != null) {//注销广播
        unregisterReceiver(mUpdateReceiver);
    }
}

数据库.png


下载完后,安装正常,打开正常,下载OK

掘金.png

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏JetpropelledSnake

Django学习笔记之利用Form和Ajax实现注册功能

1695
来自专栏向治洪

开源数据库框架greenDAO

最近在对开发项目的性能进行优化。由于项目里涉及了大量的缓存处理和数据库运用,需要对数据库进行频繁的读写、查询等操作。因此首先想到了对整个项目的数据库框架进行优...

2605
来自专栏腾讯Bugly的专栏

不给“爸爸”添麻烦 - iTOP iOS 动态库改造

苹果官方文档 对提交商店 APP 的二进制文件中__TEXT段大小有限制,超过大小限制的应用在提交评审的时候会被拒绝...

5969
来自专栏GopherCoder

『No19: Gorm 上手指南』

如果你是做后端开发的,日常工作中,除了熟悉编程语言之外,数据库怕是最常用的技术了吧。

6711
来自专栏MasiMaro 的技术博文

使用FormatMessage函数编写一个内核错误码查看器

在编写驱动程序的时候,常用的一个结构是NTSTATUS,它来表示操作是否成功,但是对于失败的情况它的返回码过多,不可能记住所有的情况,应用层有一个GetLast...

952
来自专栏noteless

springmvc 项目完整示例02 项目创建-eclipse创建动态web项目 配置文件 junit单元测试

spring原理案例-基本项目搭建 01 spring framework 下载 官网下载spring jar包

1862
来自专栏KK的小酒馆

SQlite数据库简介Android网络与数据存储

SQLite看名字就知道是个数据库,Android专门为移动端内置了此种轻量级工具,并且为了方便在Java语言中进行数据库操作,编写了SQLiteOpenHel...

1423
来自专栏生信宝典

基因组分析中多物种同源基因的鉴定和筛选

OrthoMCL能做什么 Orthologs are homologs separated by speciation events. Paralogs are...

4087
来自专栏刘望舒

Android系统层Watchdog机制源码分析

一:为什么需要看门狗? Watchdog,初次见到这个词语是在大学的单片机书上, 谈到了看门狗定时器. 在很早以前那个单片机刚发展的时候, 单片机容易受到外界工...

2537
来自专栏高性能服务器开发

从零学习开源项目系列(四)LogServer源码探究

这是从零学习开源项目的第四篇,上一篇是《从零学习开源项目系列(三) CSBattleMgr服务源码研究》,这篇文章我们一起来学习LogServer,中文意思可能...

2562

扫码关注云+社区

领取腾讯云代金券