`
ijavagos
  • 浏览: 1187436 次
  • 性别: Icon_minigender_2
  • 来自: 北京
文章分类
社区版块
存档分类
最新评论

Android(五)数据存储之五网络多线程断点下载

阅读更多

们编写的是Andorid的HTTP多线程断点下载应用程序。因为之间我们学习的学习积累,直接使用单线程下载HTTP文件对我们来说是一件非常简单的事。那么,多线程断点下载的难点在哪里?1.多线程下载,2.支持断点。

多线程下载:
2010-03-03 传智播客—Android(五)数据存储之五网络多线程断点下载 - 长城 - 长城

如何才能从文件的指定位置处开始下载文件?(比如从50MB开始)这一点我们可以通过HTTP请求信息头来设置,还记得HTTP请求信息头的“Range”属性吗?

断点:

首要问题(多线程下载)已经被我们解决了,支持断点下载想必大家也已经想到了。就是将下载的进度保存到文件中,但在Android中却不能这么做。通过老黎的试验,在Android平台中,我们需要向文件中写出下载的文件数据,还需要向另一个文件中写出下载进度,这样会出错。这样会导致有一个文件的内容没有被写出。所以我们就不能以文件的方式来保存下载进度,但可以通过数据库的方式保存下载进度。

这两大问题我们已经有了解决思路,那么就开始动手编写吧!

1.创建Android工程

Project name:MulThreadDownloader

BuildTarget:Android2.1

Application name:多线程断点下载

Package name:com.changcheng.download

Create Activity:MulThreadDownloader

Min SDK Version:7

2.AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android"

package="com.changcheng.download"

android:versionCode="1"

android:versionName="1.0">

<application android:icon="@drawable/icon" android:label="@string/app_name">

<activity android:name=".MulThreadDownloader"

android:label="@string/app_name">

<intent-filter>

<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />

</intent-filter>

</activity>

</application>

<uses-sdk android:minSdkVersion="7" />

<!-- 在SDCard中创建与删除文件权限 -->

<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>

<!-- 往SDCard写入数据权限 -->

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

<!-- 访问internet权限 -->

<uses-permission android:name="android.permission.INTERNET"/>

</manifest>

3.strings.xml

<?xml version="1.0" encoding="utf-8"?>

<resources>

<string name="hello">Hello World, DownloadActivity!</string>

<string name="app_name">多线程断点下载</string>

<string name="path">下载路径</string>

<string name="downloadbutton">下载</string>

<string name="sdcarderror">SDCard不存在或者写保护</string>

</resources>

4.main.xml

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"

android:orientation="vertical"

android:layout_width="fill_parent"

android:layout_height="fill_parent"

>

<!-- 下载路径 -->

<TextView

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="@string/path"

/>

<EditText

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:text="http://www.winrar.com.cn/download/wrar380sc.exe"

android:id="@+id/path"

/>

<!-- 下载按钮 -->

<Button

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="@string/downloadbutton"

android:id="@+id/button"

/>

<!-- 进度条 -->

<ProgressBar

android:layout_width="fill_parent"

android:layout_height="20dip"

style="?android:attr/progressBarStyleHorizontal"

android:id="@+id/downloadbar"/>

<!-- 进度% -->

<TextView

android:layout_width="fill_parent"

android:layout_height="wrap_content"

android:gravity="center"

android:id="@+id/resultView"

/>

</LinearLayout>

5.MulThreadDownloader

package com.changcheng.download;

import java.io.File;

import com.changcheng.net.download.DownloadProgressListener;

import com.changcheng.net.download.FileDownloader;

import com.changcheng.download.R;

import android.app.Activity;

import android.os.Bundle;

import android.os.Environment;

import android.os.Handler;

import android.os.Message;

import android.view.View;

import android.widget.Button;

import android.widget.EditText;

import android.widget.ProgressBar;

import android.widget.TextView;

import android.widget.Toast;

public class MulThreadDownloader extends Activity {

private EditText pathText;

private ProgressBar progressBar;

private TextView resultView;

private Handler handler = new Handler(){

@Override

public void handleMessage(Message msg) {

if(!Thread.currentThread().isInterrupted()){

switch (msg.what) {

case 1:

// 获取当前文件下载的进度

int size = msg.getData().getInt("size");

progressBar.setProgress(size);

int result = (int)(((float)size/(float)progressBar.getMax()) * 100);

resultView.setText(result+ "%");

if(progressBar.getMax() == size){

Toast.makeText(MulThreadDownloader.this, "文件下载完成", 1).show();

}

break;

case -1:

String error = msg.getData().getString("error");

Toast.makeText(MulThreadDownloader.this, error, 1).show();

break;

}

}

super.handleMessage(msg);

}

};

@Override

public void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.main);

pathText = (EditText)this.findViewById(R.id.path);

progressBar = (ProgressBar)this.findViewById(R.id.downloadbar);

resultView = (TextView)this.findViewById(R.id.resultView);

Button button = (Button)this.findViewById(R.id.button);

button.setOnClickListener(new View.OnClickListener() {

@Override

public void onClick(View v) {

String path = pathText.getText().toString();

if(Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)){

//下载文件需要很长的时间,主线程是不能够长时间被阻塞,如果主线程被长时间阻塞, 那么Android被回收应用

download(path, Environment.getExternalStorageDirectory());

}else{

Toast.makeText(MulThreadDownloader.this, R.string.sdcarderror, 1).show();

}

}

});

}

/**

* 下载文件

* @param path 下载路径

* @param saveDir 文件保存目录

*/

//对于Android的UI控件,只能由主线程负责显示界面的更新,其他线程不能直接更新UI控件的显示

public void download(final String path, final File saveDir){

new Thread(new Runnable() {

@Override

public void run() {

FileDownloader downer = new FileDownloader(MulThreadDownloader.this, path, saveDir, 3);

progressBar.setMax(downer.getFileSize());//设置进度条的最大刻度

try {

downer.download(new DownloadProgressListener(){

@Override

public void onDownloadSize(int size) {

Message msg = new Message();

msg.what = 1;

msg.getData().putInt("size", size);

handler.sendMessage(msg);//发送消息

}});

} catch (Exception e) {

Message msg = new Message();

msg.what = -1;

msg.getData().putString("error", "下载失败");

handler.sendMessage(msg);

}

}

}).start();

}

}

6.FileDownload

package com.changcheng.net.download;

import java.io.File;

import java.io.RandomAccessFile;

import java.net.HttpURLConnection;

import java.net.URL;

import java.util.LinkedHashMap;

import java.util.Map;

import java.util.UUID;

import java.util.concurrent.ConcurrentHashMap;

import java.util.regex.Matcher;

import java.util.regex.Pattern;

import com.changcheng.download.service.FileService;

import android.content.Context;

import android.util.Log;

/**

* 文件下载器

* @author lihuoming@sohu.com

*

*/

public class FileDownloader {

private Context context;

private FileService fileService;

private static final String TAG = "FileDownloader";

/* 已下载文件大小 */

private int downloadSize = 0;

/* 原始文件大小 */

private int fileSize = 0;

/* 线程数 */

private DownloadThread[] threads;

/* 下载路径 */

private URL url;

/* 本地保存文件 */

private File saveFile;

/* 下载记录文件 */

private File logFile;

/* 缓存各线程最后下载的位置*/

private Map<Integer, Integer> data = new ConcurrentHashMap<Integer, Integer>();

/* 每条线程下载的大小 */

private int block;

private String downloadUrl;//下载路径

/**

* 获取线程数

*/

public int getThreadSize() {

return threads.length;

}

/**

* 获取文件大小

* @return

*/

public int getFileSize() {

return fileSize;

}

/**

* 累计已下载大小

* @param size

*/

protected synchronized void append(int size) {

downloadSize += size;

}

/**

* 更新指定线程最后下载的位置

* @param threadId 线程id

* @param pos 最后下载的位置

*/

protected void update(int threadId, int pos) {

this.data.put(threadId, pos);

}

/**

* 保存记录文件

*/

protected synchronized void saveLogFile() {

this.fileService.update(this.downloadUrl, this.data);

}

/**

* 构建文件下载器

* @param downloadUrl 下载路径

* @param fileSaveDir 文件保存目录

* @param threadNum 下载线程数

*/

public FileDownloader(Context context, String downloadUrl, File fileSaveDir, int threadNum) {

try {

this.context = context;

this.downloadUrl = downloadUrl;

fileService = new FileService(context);

this.url = new URL(downloadUrl);

if(!fileSaveDir.exists()) fileSaveDir.mkdirs();

this.threads = new DownloadThread[threadNum];

HttpURLConnection conn = (HttpURLConnection) url.openConnection();

conn.setConnectTimeout(6*1000);

conn.setRequestMethod("GET");

conn.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*");

conn.setRequestProperty("Accept-Language", "zh-CN");

conn.setRequestProperty("Referer", downloadUrl);

conn.setRequestProperty("Charset", "UTF-8");

conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)");

conn.setRequestProperty("Connection", "Keep-Alive");

conn.connect();

printResponseHeader(conn);

if (conn.getResponseCode()==200) {

this.fileSize = conn.getContentLength();//根据响应获取文件大小

if (this.fileSize <= 0) throw new RuntimeException("无法获知文件大小 ");

String filename = getFileName(conn);

this.saveFile = new File(fileSaveDir, filename);/* 保存文件 */

Map<Integer, Integer> logdata = fileService.getData(downloadUrl);

if(logdata.size()>0){

data.putAll(logdata);

}

this.block = this.fileSize / this.threads.length + 1;

if(this.data.size()==this.threads.length){

for (int i = 0; i < this.threads.length; i++) {

this.downloadSize += this.data.get(i+1)-(this.block * i);

}

print("已经下载的长度"+ this.downloadSize);

}

}else{

throw new RuntimeException("服务器响应错误 ");

}

} catch (Exception e) {

print(e.toString());

throw new RuntimeException("连接不到下载路径 ");

}

}

/**

* 获取文件名

*/

private String getFileName(HttpURLConnection conn) {

String filename = this.url.toString().substring(this.url.toString().lastIndexOf('/') + 1);

if(filename==null || "".equals(filename.trim())){//如果获取不到文件名称

for (int i = 0;; i++) {

String mine = conn.getHeaderField(i);

if (mine == null) break;

if("content-disposition".equals(conn.getHeaderFieldKey(i).toLowerCase())){

Matcher m = Pattern.compile(".*filename=(.*)").matcher(mine.toLowerCase());

if(m.find()) return m.group(1);

}

}

filename = UUID.randomUUID()+ ".tmp";//默认取一个文件名

}

return filename;

}

/**

* 开始下载文件

* @param listener 监听下载数量的变化,如果不需要了解实时下载的数量,可以设置为null

* @return 已下载文件大小

* @throws Exception

*/

public int download(DownloadProgressListener listener) throws Exception{

try {

if(this.data.size() != this.threads.length){

this.data.clear();

for (int i = 0; i < this.threads.length; i++) {

this.data.put(i+1, this.block * i);

}

}

for (int i = 0; i < this.threads.length; i++) {

int downLength = this.data.get(i+1) - (this.block * i);

if(downLength < this.block && this.data.get(i+1)<this.fileSize){ //该线程未完成下载时,继续下载

RandomAccessFile randOut = new RandomAccessFile(this.saveFile, "rw");

if(this.fileSize>0) randOut.setLength(this.fileSize);

randOut.seek(this.data.get(i+1));

this.threads[i] = new DownloadThread(this, this.url, randOut, this.block, this.data.get(i+1), i+1);

this.threads[i].setPriority(7);

this.threads[i].start();

}else{

this.threads[i] = null;

}

}

this.fileService.save(this.downloadUrl, this.data);

boolean notFinish = true;//下载未完成

while (notFinish) {// 循环判断是否下载完毕

Thread.sleep(900);

notFinish = false;//假定下载完成

for (int i = 0; i < this.threads.length; i++){

if (this.threads[i] != null && !this.threads[i].isFinish()) {

notFinish = true;//下载没有完成

if(this.threads[i].getDownLength() == -1){//如果下载失败,再重新下载

RandomAccessFile randOut = new RandomAccessFile(this.saveFile, "rw");

randOut.seek(this.data.get(i+1));

this.threads[i] = new DownloadThread(this, this.url, randOut, this.block, this.data.get(i+1), i+1);

this.threads[i].setPriority(7);

this.threads[i].start();

}

}

}

if(listener!=null) listener.onDownloadSize(this.downloadSize);

}

fileService.delete(this.downloadUrl);

} catch (Exception e) {

print(e.toString());

throw new Exception("下载失败");

}

return this.downloadSize;

}

/**

* 获取Http响应头字段

* @param http

* @return

*/

public static Map<String, String> getHttpResponseHeader(HttpURLConnection http) {

Map<String, String> header = new LinkedHashMap<String, String>();

for (int i = 0;; i++) {

String mine = http.getHeaderField(i);

if (mine == null) break;

header.put(http.getHeaderFieldKey(i), mine);

}

return header;

}

/**

* 打印Http头字段

* @param http

*/

public static void printResponseHeader(HttpURLConnection http){

Map<String, String> header = getHttpResponseHeader(http);

for(Map.Entry<String, String> entry : header.entrySet()){

String key = entry.getKey()!=null ? entry.getKey()+ ":" : "";

print(key+ entry.getValue());

}

}

private static void print(String msg){

Log.i(TAG, msg);

}

}

7.DownloadProgressListener

package com.changcheng.net.download;

public interface DownloadProgressListener {

public void onDownloadSize(int size);

}

8.FileService

package com.changcheng.download.service;

import java.util.HashMap;

import java.util.Map;

import android.content.Context;

import android.database.Cursor;

import android.database.sqlite.SQLiteDatabase;

/**

* 业务bean

*

*/

public class FileService {

private DBOpenHelper openHelper;

public FileService(Context context) {

openHelper = new DBOpenHelper(context);

}

/**

* 获取线程最后下载位置

* @param path

* @return

*/

public Map<Integer, Integer> getData(String path){

SQLiteDatabase db = openHelper.getReadableDatabase();

Cursor cursor = db.rawQuery("select threadid, position from filedown where downpath=?", new String[]{path});

Map<Integer, Integer> data = new HashMap<Integer, Integer>();

while(cursor.moveToNext()){

data.put(cursor.getInt(0), cursor.getInt(1));

}

cursor.close();

db.close();

return data;

}

/**

* 保存下载线程初始位置

* @param path

* @param map

*/

public void save(String path, Map<Integer, Integer> map){//int threadid, int position

SQLiteDatabase db = openHelper.getWritableDatabase();

db.beginTransaction();

try{

for(Map.Entry<Integer, Integer> entry : map.entrySet()){

db.execSQL("insert into filedown(downpath, threadid, position) values(?,?,?)",

new Object[]{path, entry.getKey(), entry.getValue()});

}

db.setTransactionSuccessful();

}finally{

db.endTransaction();

}

db.close();

}

/**

* 实时更新线程的最后下载位置

* @param path

* @param map

*/

public void update(String path, Map<Integer, Integer> map){

SQLiteDatabase db = openHelper.getWritableDatabase();

db.beginTransaction();

try{

for(Map.Entry<Integer, Integer> entry : map.entrySet()){

db.execSQL("update filedown set position=? where downpath=? and threadid=?",

new Object[]{entry.getValue(), path, entry.getKey()});

}

db.setTransactionSuccessful();

}finally{

db.endTransaction();

}

db.close();

}

/**

* 当文件下载完成后,清掉该文件对应的下载记录

* @param path

*/

public void delete(String path){

SQLiteDatabase db = openHelper.getWritableDatabase();

db.execSQL("delete from filedown where downpath=?", new Object[]{path});

db.close();

}

}

9.DownloadThread

package com.changcheng.net.download;

import java.io.InputStream;

import java.io.RandomAccessFile;

import java.net.HttpURLConnection;

import java.net.URL;

import android.util.Log;

public class DownloadThread extends Thread {

private static final String TAG = "DownloadThread";

private RandomAccessFile saveFile;

private URL downUrl;

private int block;

/* 下载开始位置 */

private int threadId = -1;

private int startPos;

private int downLength;

private boolean finish = false;

private FileDownloader downloader;

public DownloadThread(FileDownloader downloader, URL downUrl, RandomAccessFile saveFile, int block, int startPos, int threadId) {

this.downUrl = downUrl;

this.saveFile = saveFile;

this.block = block;

this.startPos = startPos;

this.downloader = downloader;

this.threadId = threadId;

this.downLength = startPos - (block * (threadId - 1));

}

@Override

public void run() {

if(downLength < block){//未下载完成

try {

HttpURLConnection http = (HttpURLConnection) downUrl.openConnection();

http.setRequestMethod("GET");

http.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*");

http.setRequestProperty("Accept-Language", "zh-CN");

http.setRequestProperty("Referer", downUrl.toString());

http.setRequestProperty("Charset", "UTF-8");

http.setRequestProperty("Range", "bytes=" + this.startPos + "-");

http.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)");

http.setRequestProperty("Connection", "Keep-Alive");

InputStream inStream = http.getInputStream();

int max = block>1024 ? 1024 : (block>10 ? 10 : 1);

byte[] buffer = new byte[max];

int offset = 0;

print("线程 " + this.threadId + "从位置"+ this.startPos+ "开始下载 ");

while (downLength < block && (offset = inStream.read(buffer, 0, max)) != -1) {

saveFile.write(buffer, 0, offset);

downLength += offset;

downloader.update(this.threadId, block * (threadId - 1) + downLength);

downloader.saveLogFile();

downloader.append(offset);

int spare = block-downLength;//求剩下的字节数

if(spare < max) max = (int) spare;

}

saveFile.close();

inStream.close();

print("线程 " + this.threadId + "完成下载 ");

this.finish = true;

this.interrupt();

} catch (Exception e) {

this.downLength = -1;

print("线程"+ this.threadId+ ":"+ e);

}

}

}

private static void print(String msg){

Log.i(TAG, msg);

}

/**

* 下载是否完成

* @return

*/

public boolean isFinish() {

return finish;

}

/**

* 已经下载的内容大小

* @return 如果返回值为-1,代表下载失败

*/

public long getDownLength() {

return downLength;

}

}

11.DBOpenHelper

package com.changcheng.download.service;

import android.content.Context;

import android.database.sqlite.SQLiteDatabase;

import android.database.sqlite.SQLiteOpenHelper;

public class DBOpenHelper extends SQLiteOpenHelper {

private static final String DBNAME = "download.db";

private static final int VERSION = 2;

public DBOpenHelper(Context context) {

super(context, DBNAME, null, VERSION);

}

@Override

public void onCreate(SQLiteDatabase db) {

db.execSQL("CREATE TABLE IF NOT EXISTS filedown (id integer primary key autoincrement, downpath varchar(100), threadid INTEGER, position INTEGER)");

}

@Override

public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

db.execSQL("DROP TABLE IF EXISTS filedown");

onCreate(db);

}

}

结束!

分享到:
评论

相关推荐

    Android开发--多线程下载加断点续传

    1.多线程下载: 首先通过下载总线程数来划分文件的下载区域:利用int range = fileSize / threadCount;得到每一段下载量;每一段的位置是i * range到(i + 1) * rang - 1,注意最后一段的位置是到filesize - 1; ...

    Android多线程下载文件

    Android多线程下载文件,支持断点续传,这里用的数据库存储

    Android多线程下载

    功能包括多线程下载,progressbar更新,保存sd卡、多文件同名文件的命名方法以及检测存储空间等,博客地址为:http://blog.csdn.net/xutao3716/article/details/49357529

    多线程断点续传程序 java版本

    下载,支持多线程,支持断点续传 下载记录在数据库中 如果在android手机中可以存储在SQLite数据库里

    Android多线程断点续传小例子

    本例子是一个多线程支持断点续传下载文件和边缓冲边播放音乐的例子源码,亲测有效。点击下载以后会有一个下载进度条,如果想要美观一些可以自己给进度条使用一些比较好看的样式。点击暂停以后完全结束程序再重新打开...

    多线程断点续传

    基于文件存储写的一个多线程断点上传下载Demo

    Android入门:多线程断点下载详细介绍

    本案例在于实现文件的多线程断点下载,即文件在下载一部分中断后,可继续接着已有进度下载,并通过进度条显示进度。也就是说在文件开始下载的同时,自动创建每个线程的下载进度的本地文件,下载中断后,重新进入应用...

    Android实现断点多线程下载

    断点多线程下载的几个关键点:①:得到要下载的文件大小后,均分给几个线程。②:使用RandomAccessFile类进行读写,可以指定开始写入的位置。③:数据库保存下载信息,下一次继续下载的时候从数据库取出数据,然后从...

    多线程断点续传+在线音乐缓冲播放

    多线程断点续传+在线音乐缓冲播放源码点击下载以后会有一个下载进度条,如果想要美观一些可以自己给进度条使用一些比较好看的样式。点击暂停以后完全结束程序再重新打开程序点击开始下载会在上次下载到的地方继续...

    PC版与Android手机版带断点续传的多线程下载

    一、多线程下载  多线程下载就是抢占服务器资源  原理:服务器CPU 分配给每条线程的时间片相同,服务器带宽平均分配给每条线程,所以客户端开启的线程越多,就能抢占到更多的服务器资源。  1、设置开启线程数,...

    Android 断点续传原理以及实现

    在本地下载过程中要使用数据库实时存储到底存储到文件的哪个位置了,这样点击开始继续传递时,才能通过HTTP的GET请求中的setRequestProperty()方法可以告诉服务器,数据从哪里开始,到哪里结束。同时在本地的文件...

    黑马程序员 安卓学院 万元哥项目经理 分享220个代码实例

    |--利用FinalHttp实现多线程断点续传 |--加密之MD5 |--动画Animation详解 |--动画之view左右抖动 |--动画之移动动画 |--动画之组合动画 |--动画之缩放动画ScaleAnimation |--反序列化对象 |--发送短信 读天气 调音量...

    android开发秘籍

    1.8.6 android market 的候补之选 17 第2 章 应用程序基础知识:activity 和intent 18 2.1 android 应用程序预览 18 2.1.1 秘诀1:创建工程并新建activity 19 2.1.2 工程目录结构及自动生成内容 20 2.1.3 android...

    JAVA上百实例源码以及开源项目源代码

     Tcp服务端与客户端的JAVA实例源代码,一个简单的Java TCP服务器端程序,别外还有一个客户端的程序,两者互相配合可以开发出超多的网络程序,这是最基础的部分。 递归遍历矩阵 1个目标文件,简单! 多人聊天室 3...

    JAVA上百实例源码以及开源项目

     Tcp服务端与客户端的JAVA实例源代码,一个简单的Java TCP服务器端程序,别外还有一个客户端的程序,两者互相配合可以开发出超多的网络程序,这是最基础的部分。 递归遍历矩阵 1个目标文件,简单! 多人聊天室 3...

Global site tag (gtag.js) - Google Analytics