频道栏目
首页 > 程序开发 > 移动开发 > Android > 正文
Android使用OKHttp3实现下载(断点续传、显示进度)
2018-05-10 10:47:49      个评论    来源:厚德出品,必是精品  
收藏   我要投稿

OKHttp3是如今非常流行的Android网络请求框架,那么如何利用Android实现断点续传呢

准备阶段

我们会用到OKHttp3来做网络请求,使用RxJava来实现线程的切换,并且开启Java8来启用Lambda表达式,毕竟RxJava实现线程切换非常方便,而且数据流的形式也非常舒服,同时Lambda和RxJava配合食用味道更佳

打开我们的app Module下的build.gradle,代码如下

[plain]view plaincopy

applyplugin:'com.android.application'

android{

compileSdkVersion24

buildToolsVersion"24.0.3"

defaultConfig{

applicationId"com.lanou3g.downdemo"

minSdkVersion15

targetSdkVersion24

versionCode1

versionName"1.0"

testInstrumentationRunner"android.support.test.runner.AndroidJUnitRunner"

//为了开启Java8

jackOptions{

enabledtrue;

}

}

buildTypes{

release{

minifyEnabledfalse

proguardFilesgetDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro'

}

}

//开启Java1.8能够使用lambda表达式

compileOptions{

sourceCompatibilityJavaVersion.VERSION_1_8

targetCompatibilityJavaVersion.VERSION_1_8

}

}

dependencies{

compilefileTree(dir:'libs',include:['*.jar'])

androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2',{

excludegroup:'com.android.support',module:'support-annotations'

})

compile'com.android.support:appcompat-v7:24.1.1'

testCompile'junit:junit:4.12'

//OKHttp

compile'com.squareup.okhttp3:okhttp:3.6.0'

//RxJava和RxAndroid用来做线程切换的

compile'io.reactivex.rxjava2:rxandroid:2.0.1'

compile'io.reactivex.rxjava2:rxjava:2.0.1'

}OKHttp和RxJava,RxAndroid使用的都是最新的版本,并且配置开启了Java8

布局文件

接着开始书写布局文件

[html]view plaincopy

android:id="@+id/activity_main"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:paddingBottom="@dimen/activity_vertical_margin"

android:paddingLeft="@dimen/activity_horizontal_margin"

android:paddingRight="@dimen/activity_horizontal_margin"

android:paddingTop="@dimen/activity_vertical_margin"

android:orientation="vertical"

tools:context="com.lanou3g.downdemo.MainActivity">

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:orientation="horizontal">

android:id="@+id/main_progress1"

android:layout_width="0dp"

android:layout_weight="1"

android:layout_height="match_parent"

style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>

android:id="@+id/main_btn_down1"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="下载1"/>

android:id="@+id/main_btn_cancel1"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="取消1"/>

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:orientation="horizontal">

android:id="@+id/main_progress2"

android:layout_width="0dp"

android:layout_weight="1"

android:layout_height="match_parent"

style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>

android:id="@+id/main_btn_down2"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="下载2"/>

android:id="@+id/main_btn_cancel2"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="取消2"/>

android:layout_width="match_parent"

android:layout_height="wrap_content"

android:orientation="horizontal">

android:id="@+id/main_progress3"

android:layout_width="0dp"

android:layout_weight="1"

android:layout_height="match_parent"

style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>

android:id="@+id/main_btn_down3"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="下载3"/>

android:id="@+id/main_btn_cancel3"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="取消3"/>

大概是这个样子的

\

3个ProgressBar就是为了显示进度的,每个ProgressBar对应2个Button,一个是开始下载,一个是暂停(取消)下载,这里需要说明的是,对下载来说暂停和取消没有什么区别,除非当取消的时候,会顺带把临时文件都删除了,在本例里是不区分他俩的.

Application

我们这里需要用到一些文件路径,有一个全局Context会比较方便, 而Application也是Context的子类,使用它的是最方便的,所以我们写一个类来继承Application[java]view plaincopy

packagecom.lanou3g.downdemo;

importandroid.app.Application;

importandroid.content.Context;

/**

*Createdby陈丰尧on2017/2/2.

*/

publicclassMyAppextendsApplication{

publicstaticContextsContext;//全局的Context对象

@Override

publicvoidonCreate(){

super.onCreate();

sContext=this;

}

}可以看到,我们就是要获得一个全局的Context对象的我们在AndroidManifest中注册一下我们的Application,同时再把我们所需要的权限给上[html]view plaincopy

android:allowBackup="true"

android:icon="@mipmap/ic_launcher"

android:label="@string/app_name"

android:supportsRtl="true"

android:name=".MyApp"

android:theme="@style/AppTheme">

我们只需要一个网络权限,在application标签下,添加name属性,来指向我们的Application

DownloadManager

接下来是核心代码了,就是我们的DownloadManager,先上代码[java]view plaincopy

packagecom.lanou3g.downdemo;

importjava.io.File;

importjava.io.FileOutputStream;

importjava.io.IOException;

importjava.io.InputStream;

importjava.util.HashMap;

importjava.util.concurrent.atomic.AtomicReference;

importio.reactivex.Observable;

importio.reactivex.ObservableEmitter;

importio.reactivex.ObservableOnSubscribe;

importio.reactivex.android.schedulers.AndroidSchedulers;

importio.reactivex.schedulers.Schedulers;

importokhttp3.Call;

importokhttp3.OkHttpClient;

importokhttp3.Request;

importokhttp3.Response;

/**

*Createdby陈丰尧on2017/2/2.

*/

publicclassDownloadManager{

privatestaticfinalAtomicReferenceINSTANCE=newAtomicReference<>();

privateHashMapdownCalls;//用来存放各个下载的请求

privateOkHttpClientmClient;//OKHttpClient;

//获得一个单例类

publicstaticDownloadManagergetInstance(){

for(;;){

DownloadManagercurrent=INSTANCE.get();

if(current!=null){

returncurrent;

}

current=newDownloadManager();

if(INSTANCE.compareAndSet(null,current)){

returncurrent;

}

}

}

privateDownloadManager(){

downCalls=newHashMap<>();

mClient=newOkHttpClient.Builder().build();

}

/**

*开始下载

*

*@paramurl下载请求的网址

*@paramdownLoadObserver用来回调的接口

*/

publicvoiddownload(Stringurl,DownLoadObserverdownLoadObserver){

Observable.just(url)

.filter(s->!downCalls.containsKey(s))//call的map已经有了,就证明正在下载,则这次不下载

.flatMap(s->Observable.just(createDownInfo(s)))

.map(this::getRealFileName)//检测本地文件夹,生成新的文件名

.flatMap(downloadInfo->Observable.create(newDownloadSubscribe(downloadInfo)))//下载

.observeOn(AndroidSchedulers.mainThread())//在主线程回调

.subscribeOn(Schedulers.io())//在子线程执行

.subscribe(downLoadObserver);//添加观察者

}

publicvoidcancel(Stringurl){

Callcall=downCalls.get(url);

if(call!=null){

call.cancel();//取消

}

downCalls.remove(url);

}

/**

*创建DownInfo

*

*@paramurl请求网址

*@returnDownInfo

*/

privateDownloadInfocreateDownInfo(Stringurl){

DownloadInfodownloadInfo=newDownloadInfo(url);

longcontentLength=getContentLength(url);//获得文件大小

downloadInfo.setTotal(contentLength);

StringfileName=url.substring(url.lastIndexOf("/"));

downloadInfo.setFileName(fileName);

returndownloadInfo;

}

privateDownloadInfogetRealFileName(DownloadInfodownloadInfo){

StringfileName=downloadInfo.getFileName();

longdownloadLength=0,contentLength=downloadInfo.getTotal();

Filefile=newFile(MyApp.sContext.getFilesDir(),fileName);

if(file.exists()){

//找到了文件,代表已经下载过,则获取其长度

downloadLength=file.length();

}

//之前下载过,需要重新来一个文件

inti=1;

while(downloadLength>=contentLength){

intdotIndex=fileName.lastIndexOf(".");

StringfileNameOther;

if(dotIndex==-1){

fileNameOther=fileName+"("+i+")";

}else{

fileNameOther=fileName.substring(0,dotIndex)

+"("+i+")"+fileName.substring(dotIndex);

}

FilenewFile=newFile(MyApp.sContext.getFilesDir(),fileNameOther);

file=newFile;

downloadLength=newFile.length();

i++;

}

//设置改变过的文件名/大小

downloadInfo.setProgress(downloadLength);

downloadInfo.setFileName(file.getName());

returndownloadInfo;

}

privateclassDownloadSubscribeimplementsObservableOnSubscribe{

privateDownloadInfodownloadInfo;

publicDownloadSubscribe(DownloadInfodownloadInfo){

this.downloadInfo=downloadInfo;

}

@Override

publicvoidsubscribe(ObservableEmittere)throwsException{

Stringurl=downloadInfo.getUrl();

longdownloadLength=downloadInfo.getProgress();//已经下载好的长度

longcontentLength=downloadInfo.getTotal();//文件的总长度

//初始进度信息

e.onNext(downloadInfo);

Requestrequest=newRequest.Builder()

//确定下载的范围,添加此头,则服务器就可以跳过已经下载好的部分

.addHeader("RANGE","bytes="+downloadLength+"-"+contentLength)

.url(url)

.build();

Callcall=mClient.newCall(request);

downCalls.put(url,call);//把这个添加到call里,方便取消

Responseresponse=call.execute();

Filefile=newFile(MyApp.sContext.getFilesDir(),downloadInfo.getFileName());

InputStreamis=null;

FileOutputStreamfileOutputStream=null;

try{

is=response.body().byteStream();

fileOutputStream=newFileOutputStream(file,true);

byte[]buffer=newbyte[2048];//缓冲数组2kB

intlen;

while((len=is.read(buffer))!=-1){

fileOutputStream.write(buffer,0,len);

downloadLength+=len;

downloadInfo.setProgress(downloadLength);

e.onNext(downloadInfo);

}

fileOutputStream.flush();

downCalls.remove(url);

}finally{

//关闭IO流

IOUtil.closeAll(is,fileOutputStream);

}

e.onComplete();//完成

}

}

/**

*获取下载长度

*

*@paramdownloadUrl

*@return

*/

privatelonggetContentLength(StringdownloadUrl){

Requestrequest=newRequest.Builder()

.url(downloadUrl)

.build();

try{

Responseresponse=mClient.newCall(request).execute();

if(response!=null&&response.isSuccessful()){

longcontentLength=response.body().contentLength();

response.close();

returncontentLength==0DownloadInfo.TOTAL_ERROR:contentLength;

}

}catch(IOExceptione){

e.printStackTrace();

}

returnDownloadInfo.TOTAL_ERROR;

}

}

代码稍微有点长,关键部位我都加了注释了,我们挑关键地方看看首先我们这个类是单例类,我们下载只需要一个OKHttpClient就足够了,所以我们让构造方法私有,而单例类的获取实例方法就是这个getInstance();当然大家用别的方式实现单例也可以的,然后我们在构造方法里初始化我们的HttpClient,并且初始化一个HashMap,用来放所有的网络请求的,这样当我们取消下载的时候,就可以找到url对应的网络请求然后把它取消掉就可以了接下来就是核心的download方法了,首先是参数,第一个参数url不用多说,就是请求的网址,第二个参数是一个Observer对象,因为我们使用的是RxJava,并且没有特别多复杂的方法,所以就没单独写接口,而是谢了一个Observer对象来作为回调,接下来是DownLoadObserver的代码[java]view plaincopy

packagecom.lanou3g.downdemo;

importio.reactivex.Observer;

importio.reactivex.disposables.Disposable;

/**

*Createdby陈丰尧on2017/2/2.

*/

publicabstractclassDownLoadObserverimplementsObserver{

protectedDisposabled;//可以用于取消注册的监听者

protectedDownloadInfodownloadInfo;

@Override

publicvoidonSubscribe(Disposabled){

this.d=d;

}

@Override

publicvoidonNext(DownloadInfodownloadInfo){

this.downloadInfo=downloadInfo;

}

@Override

publicvoidonError(Throwablee){

e.printStackTrace();

}

}在RxJava2中 这个Observer有点变化,当注册观察者的时候,会调用onSubscribe方法,而该方法参数就是用来取消注册的,这样的改动可以更灵活的有监听者来取消监听了,我们的进度信息会一直的传送的onNext方法里,这里将下载所需要的内容封了一个类叫DownloadInfo[java]view plaincopy

packagecom.lanou3g.downdemo;

/**

*Createdby陈丰尧on2017/2/2.

*下载信息

*/

publicclassDownloadInfo{

publicstaticfinallongTOTAL_ERROR=-1;//获取进度失败

privateStringurl;

privatelongtotal;

privatelongprogress;

privateStringfileName;

publicDownloadInfo(Stringurl){

this.url=url;

}

publicStringgetUrl(){

returnurl;

}

publicStringgetFileName(){

returnfileName;

}

publicvoidsetFileName(StringfileName){

this.fileName=fileName;

}

publiclonggetTotal(){

returntotal;

}

publicvoidsetTotal(longtotal){

this.total=total;

}

publiclonggetProgress(){

returnprogress;

}

publicvoidsetProgress(longprogress){

this.progress=progress;

}

}这个类就是一些基本信息,total就是需要下载的文件的总大小,而progress就是当前下载的进度了,这样就可以计算出下载的进度信息了接着看DownloadManager的download方法,首先通过url生成一个Observable对象,然后通过filter操作符过滤一下,如果当前正在下载这个url对应的内容,那么就不下载它,接下来调用createDownInfo重新生成Observable对象,这里应该用map也是可以的,createDownInfo这个方法里会调用getContentLength来获取服务器上的文件大小,可以看一下这个方法的代码,[java]view plaincopy

/**

*获取下载长度

*

*@paramdownloadUrl

*@return

*/

privatelonggetContentLength(StringdownloadUrl){

Requestrequest=newRequest.Builder()

.url(downloadUrl)

.build();

try{

Responseresponse=mClient.newCall(request).execute();

if(response!=null&&response.isSuccessful()){

longcontentLength=response.body().contentLength();

response.close();

returncontentLength==0DownloadInfo.TOTAL_ERROR:contentLength;

}

}catch(IOExceptione){

e.printStackTrace();

}

returnDownloadInfo.TOTAL_ERROR;

}可以看到,其实就是在通过OK进行了一次网络请求,并且从返回的头信息里拿到文件的大小信息,一般这个信息都是可以拿到的,除非下载网址不是直接指向资源文件的,而是自己手写的Servlet,那就得跟后台人员沟通好了.注意,这次网络请求并没有真正的去下载文件,而是请求个大小就结束了,具体原因会在后面真正请求数据的时候解释接着download方法

获取完文件大小后,就可以去硬盘里找文件了,这里调用了getRealFileName方法

[java]view plaincopy

privateDownloadInfogetRealFileName(DownloadInfodownloadInfo){

StringfileName=downloadInfo.getFileName();

longdownloadLength=0,contentLength=downloadInfo.getTotal();

Filefile=newFile(MyApp.sContext.getFilesDir(),fileName);

if(file.exists()){

//找到了文件,代表已经下载过,则获取其长度

downloadLength=file.length();

}

//之前下载过,需要重新来一个文件

inti=1;

while(downloadLength>=contentLength){

intdotIndex=fileName.lastIndexOf(".");

StringfileNameOther;

if(dotIndex==-1){

fileNameOther=fileName+"("+i+")";

}else{

fileNameOther=fileName.substring(0,dotIndex)

+"("+i+")"+fileName.substring(dotIndex);

}

FilenewFile=newFile(MyApp.sContext.getFilesDir(),fileNameOther);

file=newFile;

downloadLength=newFile.length();

i++;

}

//设置改变过的文件名/大小

downloadInfo.setProgress(downloadLength);

downloadInfo.setFileName(file.getName());

returndownloadInfo;

}这个方法就是看本地是否有已经下载过的文件,如果有,再判断一次本地文件的大小和服务器上数据的大小,如果是一样的,证明之前下载全了,就再成一个带(1)这样的文件,而如果本地文件大小比服务器上的小的话,那么证明之前下载了一半断掉了,那么就把进度信息保存上,并把文件名也存上,看完了再回到download方法

之后就开始真正的网络请求了,这里写了一个内部类来实现ObservableOnSubscribe接口,这个接口也是RxJava2的,东西和之前一样,好像只改了名字,看一下代码

[java]view plaincopy

privateclassDownloadSubscribeimplementsObservableOnSubscribe{

privateDownloadInfodownloadInfo;

publicDownloadSubscribe(DownloadInfodownloadInfo){

this.downloadInfo=downloadInfo;

}

@Override

publicvoidsubscribe(ObservableEmittere)throwsException{

Stringurl=downloadInfo.getUrl();

longdownloadLength=downloadInfo.getProgress();//已经下载好的长度

longcontentLength=downloadInfo.getTotal();//文件的总长度

//初始进度信息

e.onNext(downloadInfo);

Requestrequest=newRequest.Builder()

//确定下载的范围,添加此头,则服务器就可以跳过已经下载好的部分

.addHeader("RANGE","bytes="+downloadLength+"-"+contentLength)

.url(url)

.build();

Callcall=mClient.newCall(request);

downCalls.put(url,call);//把这个添加到call里,方便取消

Responseresponse=call.execute();

Filefile=newFile(MyApp.sContext.getFilesDir(),downloadInfo.getFileName());

InputStreamis=null;

FileOutputStreamfileOutputStream=null;

try{

is=response.body().byteStream();

fileOutputStream=newFileOutputStream(file,true);

byte[]buffer=newbyte[2048];//缓冲数组2kB

intlen;

while((len=is.read(buffer))!=-1){

fileOutputStream.write(buffer,0,len);

downloadLength+=len;

downloadInfo.setProgress(downloadLength);

e.onNext(downloadInfo);

}

fileOutputStream.flush();

downCalls.remove(url);

}finally{

//关闭IO流

IOUtil.closeAll(is,fileOutputStream);

}

e.onComplete();//完成

}

}主要看subscribe方法

首先拿到url,当前进度信息和文件的总大小,然后开始网络请求,注意这次网络请求的时候需要添加一条头信息

[java]view plaincopy

.addHeader("RANGE","bytes="+downloadLength+"-"+contentLength)这条头信息的意思是下载的范围是多少,downloadLength是从哪开始下载,contentLength是下载到哪,当要断点续传的话必须添加这个头,让输入流跳过多少字节的形式是不行的,所以我们要想能成功的添加这条信息那么就必须对这个url请求2次,一次拿到总长度,来方便判断本地是否有下载一半的数据,第二次才开始真正的读流进行网络请求,我还想了一种思路,当文件没有下载完成的时候添加一个自定义的后缀,当下载完成再把这个后缀取消了,应该就不需要请求两次了.

接下来就是正常的网络请求,向本地写文件了,而写文件到本地这,网上大多用的是RandomAccessFile这个类,但是如果不涉及到多个部分拼接的话是没必要的,直接使用输出流就好了,在输出流的构造方法上添加一个true的参数,代表是在原文件的后面添加数据即可,而在循环里,不断的调用onNext方法发送进度信息,当写完了之后别忘了关流,同时把call对象从hashMap中移除了.这里写了一个IOUtil来关流

[java]view plaincopy

packagecom.lanou3g.downdemo;

importjava.io.Closeable;

importjava.io.IOException;

/**

*Createdby陈丰尧on2017/2/2.

*/

publicclassIOUtil{

publicstaticvoidcloseAll(Closeable...closeables){

if(closeables==null){

return;

}

for(Closeablecloseable:closeables){

if(closeable!=null){

try{

closeable.close();

}catch(IOExceptione){

e.printStackTrace();

}

}

}

}

}其实就是挨一个判断是否为空,并关闭罢了

这样download方法就完成了,剩下的就是切换线程,注册观察者了

MainActivity

最后是aty的代码[java]view plaincopy

packagecom.lanou3g.downdemo;

importandroid.net.Uri;

importandroid.support.annotation.IdRes;

importandroid.support.v7.app.AppCompatActivity;

importandroid.os.Bundle;

importandroid.view.View;

importandroid.widget.Button;

importandroid.widget.ProgressBar;

importandroid.widget.Toast;

publicclassMainActivityextendsAppCompatActivityimplementsView.OnClickListener{

privateButtondownloadBtn1,downloadBtn2,downloadBtn3;

privateButtoncancelBtn1,cancelBtn2,cancelBtn3;

privateProgressBarprogress1,progress2,progress3;

privateStringurl1="https://192.168.31.169:8080/out/dream.flac";

privateStringurl2="https://192.168.31.169:8080/out/music.mp3";

privateStringurl3="https://192.168.31.169:8080/out/code.zip";

@Override

protectedvoidonCreate(BundlesavedInstanceState){

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

downloadBtn1=bindView(R.id.main_btn_down1);

downloadBtn2=bindView(R.id.main_btn_down2);

downloadBtn3=bindView(R.id.main_btn_down3);

cancelBtn1=bindView(R.id.main_btn_cancel1);

cancelBtn2=bindView(R.id.main_btn_cancel2);

cancelBtn3=bindView(R.id.main_btn_cancel3);

progress1=bindView(R.id.main_progress1);

progress2=bindView(R.id.main_progress2);

progress3=bindView(R.id.main_progress3);

downloadBtn1.setOnClickListener(this);

downloadBtn2.setOnClickListener(this);

downloadBtn3.setOnClickListener(this);

cancelBtn1.setOnClickListener(this);

cancelBtn2.setOnClickListener(this);

cancelBtn3.setOnClickListener(this);

}

@Override

publicvoidonClick(Viewv){

switch(v.getId()){

caseR.id.main_btn_down1:

DownloadManager.getInstance().download(url1,newDownLoadObserver(){

@Override

publicvoidonNext(DownloadInfovalue){

super.onNext(value);

progress1.setMax((int)value.getTotal());

progress1.setProgress((int)value.getProgress());

}

@Override

publicvoidonComplete(){

if(downloadInfo!=null){

Toast.makeText(MainActivity.this,

downloadInfo.getFileName()+"-DownloadComplete",

Toast.LENGTH_SHORT).show();

}

}

});

break;

caseR.id.main_btn_down2:

DownloadManager.getInstance().download(url2,newDownLoadObserver(){

@Override

publicvoidonNext(DownloadInfovalue){

super.onNext(value);

progress2.setMax((int)value.getTotal());

progress2.setProgress((int)value.getProgress());

}

@Override

publicvoidonComplete(){

if(downloadInfo!=null){

Toast.makeText(MainActivity.this,

downloadInfo.getFileName()+Uri.encode("下载完成"),

Toast.LENGTH_SHORT).show();

}

}

});

break;

caseR.id.main_btn_down3:

DownloadManager.getInstance().download(url3,newDownLoadObserver(){

@Override

publicvoidonNext(DownloadInfovalue){

super.onNext(value);

progress3.setMax((int)value.getTotal());

progress3.setProgress((int)value.getProgress());

}

@Override

publicvoidonComplete(){

if(downloadInfo!=null){

Toast.makeText(MainActivity.this,

downloadInfo.getFileName()+"下载完成",

Toast.LENGTH_SHORT).show();

}

}

});

break;

caseR.id.main_btn_cancel1:

DownloadManager.getInstance().cancel(url1);

break;

caseR.id.main_btn_cancel2:

DownloadManager.getInstance().cancel(url2);

break;

caseR.id.main_btn_cancel3:

DownloadManager.getInstance().cancel(url3);

break;

}

}

privateTbindView(@IdResintid){

ViewviewById=findViewById(id);

return(T)viewById;

}

}Activity里没什么了,就是注册监听,开始下载,取消下载这些了,下面我们来看看效果吧

运行效果

\

可以看到 多个下载,断点续传什么的都已经成功了,最后我的文件网址是我自己的局域网,大家写的时候别忘了换了..

点击复制链接 与好友分享!回本站首页
上一篇:Android Activity生命周期常见问题
下一篇:Android开发底层开发概述
相关文章
图文推荐
点击排行

关于我们 | 联系我们 | 广告服务 | 投资合作 | 版权申明 | 在线帮助 | 网站地图 | 作品发布 | Vip技术培训 | 举报中心

版权所有: 红黑联盟--致力于做实用的IT技术学习网站