版權聲明:本文爲博主原創文章,未經博主允許不得轉載。
一、概述
軟件更新功能可以說是APP的標配。以前實現這個功能的時候,自己一行一行代碼重複擼,浪費時間。所以我決定實現一個萬能的可複用的更新庫。讓它支持增量更新、全量更新、靜默安裝、普通方式安裝、可以自定義UI。下面就來介紹一下我實現這個庫的主要技術點:增量更新、靜默安裝及如何封裝。
二、軟件增量更新處理流程
(1)服務端處理流程
1.驗證請求的合法性。
2.如果請求不合法(比如請求是模擬的,非客戶端發出的),則拒絕服務。
3.如果請求合法,獲取versionCode等信息,根據versionCode判斷軟件是否更新。
4.如果不需要更新,則返回對應信息。
5.如果需要更新,獲取與versionCode對應的客戶端文件的MD5,判斷該MD5值是否在歷史版本文件的MD5列表中,如果在說明支持增量更新。
6.如果不支持增量更新,則返回完整apk文件的下載鏈接。
7.如果支持增量更新,判斷對應的patch文件是否存在。
8.如果對應的patch文件不存在,調用腳本程序生成對應的patch文件,並返回該patch文件的下載鏈接。
9.如果對應的patch文件存在,則返回該patch文件的下載鏈接。
(2)客戶端處理流程
1.收集apk的基本信息,向服務端發送更新請求。
2.如果沒有更新,則做對應的提示操作。
3.如果有更新,判斷是否是增量更新還是全量更新。
4.如果是全量更新,則下載對應的apk文件,展示相應的UI,安裝apk即可。
5.如果是增量更新,則下載對應的patch文件,展示相應的UI,然後提取客戶端的apk文件到指定目錄並與patch文件合併成一個新的apk文件,判斷新合成的apk文件是否與從服務端獲取的完整的apk文件MD5的值一致,若一致說明合成成功,安裝新合成的apk文件即可,若不一致說明合成失敗,進行安裝失敗的提示。
三、增量更新的實現
通過上面的處理流程分析,我們發現實現增量更新的難點主要在於patch文件的生成、新apk文件的合成這兩個部分。這裏藉助開源的bsdiff來實現這兩部分的功能。
(1)下載二進制差分、合併工具
增量更新的實現用到第三方庫bsdiff,該庫依賴bzip2。
bsdiff目前支持Linux、Windows,同時也有Python版本的源碼。
(2)服務端patch文件的生成
服務端可以根據需要,選擇對應的版本進行patch文件的生成,比如Windows版本的生成方式如下:
同時按住Shift+右鍵,選擇“在此處打開命令窗口”,執行命令 bsdiff old.apk new.apk patch.patch即可生成patch包,至於腳本怎麼執行這些命令,請讀者自行發揮。
(3)客戶端新apk的合成實現
點擊(1)中圖片所示的”here”鏈接,下載linux版本的源代碼,同時下載bzip2的源代碼,文件目錄結構如下:
接着將bsdiff.c、bspatch.c文件中的main方法改成diff、patch
然後編寫jni代碼,調用bsdiff和bspatch的diff、patch方法
#include "jni_bsdiff.h"
#ifdef __cplusplus
extern "C" {
#endif
//定義方法宏,用於拼接方法名
#define JNI_METHOD(METHOD_NAME) \
Java_com_cy_lib_upgrade_bsdiff_BsDiff_##METHOD_NAME
extern int diff(int argc, char *argv[]);
extern int patch(int argc, char *argv[]);
JNIEXPORT jint JNICALL JNI_METHOD(diff)(JNIEnv *env, jobject object,
jstring old_path, jstring new_path, jstring patch_path) {
int argc = 4;
char *argv[argc];
argv[0] = (char *) "bsdiff";
argv[1] = (char *) (env)->GetStringUTFChars(old_path, 0);
argv[2] = (char *) (env)->GetStringUTFChars(new_path, 0);
argv[3] = (char *) (env)->GetStringUTFChars(patch_path, 0);
bool isCrash = false;
int ret;
try {
ret = diff(argc, argv);
}
catch (...) {
isCrash = true;
}
(env)->ReleaseStringUTFChars(old_path, argv[1]);
(env)->ReleaseStringUTFChars(new_path, argv[2]);
(env)->ReleaseStringUTFChars(patch_path, argv[3]);
return isCrash ? -1 : ret;
}
JNIEXPORT jint JNICALL JNI_METHOD(patch)(JNIEnv *env, jobject object,
jstring old_path, jstring new_path, jstring patch_path) {
int argc = 4;
char *argv[argc];
argv[0] = (char *) "bspatch";
argv[1] = (char *) (env)->GetStringUTFChars(old_path, 0);
argv[2] = (char *) (env)->GetStringUTFChars(new_path, 0);
argv[3] = (char *) (env)->GetStringUTFChars(patch_path, 0);
bool isCrash = false;
int ret;
try {
ret = patch(argc, argv);
}
catch (...) {
isCrash = true;
}
(env)->ReleaseStringUTFChars(old_path, argv[1]);
(env)->ReleaseStringUTFChars(new_path, argv[2]);
(env)->ReleaseStringUTFChars(patch_path, argv[3]);
return isCrash ? -1 : ret;
}
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return -1;
}
// Get jclass with env->FindClass.
// Register methods with env->RegisterNatives.
return JNI_VERSION_1_6;
}
#ifdef __cplusplus
}
#endif
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
接下來,在外層的Android.mk文件中編寫makefile腳本(gradle裏面編譯jni我不熟,哈哈哈,還是makefile用着習慣),將bsdiff、bzip2編譯成靜態庫,同時引入子目錄的Android.mk文件。
LOCAL_PATH := $(call my-dir)
#定義子目錄下面的makefile文件列表
SUB_MK_FILES := $(call all-subdir-makefiles)
#----------------------------------------------------
#將bzip2編譯成靜態庫
BZIP2_PATH :=$(LOCAL_PATH)/bzip2
BZIP2_C_FILE_LIST :=$(wildcard $(BZIP2_PATH)/*.c)
include $(CLEAR_VARS)
LOCAL_MODULE := bzip2
LOCAL_C_INCLUDES := BZIP2_PATH
LOCAL_SRC_FILES :=$(BZIP2_C_FILE_LIST:$(LOCAL_PATH)/%=%)
include $(BUILD_STATIC_LIBRARY)
#----------------------------------------------------
#----------------------------------------------------
#將bsdiff編譯成靜態庫
BSDIFF_PATH :=$(LOCAL_PATH)/bsdiff
BSDIFF_C_FILE_LIST :=$(wildcard $(BSDIFF_PATH)/*.c)
include $(CLEAR_VARS)
LOCAL_MODULE := bsdiff
LOCAL_STATIC_LIBRARIES += bzip2
LOCAL_C_INCLUDES := BSDIFF_PATH
LOCAL_SRC_FILES :=$(BSDIFF_C_FILE_LIST:$(LOCAL_PATH)/%=%)
include $(BUILD_STATIC_LIBRARY)
#----------------------------------------------------
#編譯子目錄下的make file文件
include $(SUB_MK_FILES)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
在jni_bsdiff目錄下面的Android.mk文件中編寫生成我們要用的動態庫的腳本如下
LOCAL_PATH := $(call my-dir)
#----------------------------------------------------
#將bsdiff包裝編譯成動態庫
JNI_BSDIFF_PATH :=$(LOCAL_PATH)
JNI_BSDIFF_CPP_FILE_LIST :=$(wildcard $(JNI_BSDIFF_PATH)/*.cpp)
include $(CLEAR_VARS)
LOCAL_MODULE := bsdiff_utils
LOCAL_C_INCLUDES := JNI_BSDIFF_PATH
LOCAL_SRC_FILES :=$(JNI_BSDIFF_CPP_FILE_LIST:$(LOCAL_PATH)/%=%)
LOCAL_STATIC_LIBRARIES += bsdiff
include $(BUILD_SHARED_LIBRARY)
#----------------------------------------------------
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
再接下來,在build.gradle裏面編寫編譯腳本即可
task ndkBuild(type: Exec, description: 'Compile JNI source via NDK') {
def ndkDir = project.plugins.getPlugin('com.android.library').sdkHandler.ndkFolder
print "ndkDir=" + ndkDir + "\n"
commandLine "$ndkDir\\ndk-build.cmd",
'NDK_PROJECT_PATH=build/intermediates/ndk',
'NDK_LIBS_OUT=libs',
'APP_BUILD_SCRIPT=jni/Android.mk',
'NDK_APPLICATION_MK=jni/Application.mk'
}
tasks.withType(JavaCompile) {
compileTask -> compileTask.dependsOn ndkBuild
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
如果不出意外我們的libbsdiff_utils.so就可以生成了。然後我們編寫Java層的調用代碼
public class BsDiff {
static {
try {
System.loadLibrary("bsdiff_utils");
} catch (UnsatisfiedLinkError e) {
e.printStackTrace();
}
}
public static native int diff(String oldPath, String newPath, String patchPath);
public static native int patch(String oldPath, String newPath, String patchPath);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
新apk文件的合成我們要用到的是patch方法,它的參數oldPath表示當前apk的文件路徑,newPath表示合成後的apk文件路徑,patchPath則爲下載的增量包的路徑。oldPath的取值,比較穩妥的做法是把當前安裝的apk文件拷貝到一個可讀可寫的目錄,防止bspatch對已安裝的apk文件產生破壞。附上獲取當前apk文件的路徑的代碼:
/**
* 獲取已安裝apk的路徑
*
* @param context apk的上下文
* @return apk文件路徑
*/
public static String getApkPath(Context context) {
if (context != null) {
ApplicationInfo applicationInfo = context.getApplicationContext().getApplicationInfo();
return applicationInfo.sourceDir;
}
return "";
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
四、靜默安裝實現
靜默安裝這裏採用pm install命令實現,因此應用需要獲取到Root權限才能執行成功。
/**
* 靜默安裝
*
* @param apkFilePath apk文件路徑
* @return true表示安裝成功,否則返回false
*/
public static boolean silentInstall(String apkFilePath) {
boolean isInstallOk = false;
if (isSupportSilentInstall()) {
DataOutputStream dataOutputStream = null;
BufferedReader bufferedReader = null;
try {
Process process = Runtime.getRuntime().exec("su");
dataOutputStream = new DataOutputStream(process.getOutputStream());
String command = "pm install -r " + apkFilePath + "\n";
dataOutputStream.write(command.getBytes(Charset.forName("utf-8")));
dataOutputStream.flush();
dataOutputStream.writeBytes("exit\n");
dataOutputStream.flush();
process.waitFor();
bufferedReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder msg = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
msg.append(line);
}
if (msg.toString().contains("Success")) {
isInstallOk = true;
}
} catch (Exception e) {
} finally {
if (dataOutputStream != null) {
try {
dataOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
return isInstallOk;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
五、封裝
爲了打造一個可複用的軟件更新庫,這裏根據軟件更新的流程抽象了五個接口,流程與接口的對應關係如下:
1. 更新檢測(UpdateChecker)
2. 更新檢測後的UI提示(UpdateCheckUIHandler)
3. 更新文件下載(Downloader)
4. 文件下載時的UI提示(DownloadUIHandler)
5. 安裝文件(AppInstaller)
如果使用者發現哪一步不符合自己的需求,只要實現這個步驟的接口並注入到全局配置中即可,從而實現“萬能”的軟件更新功能。
具體實現,請參照源碼:https://github.com/Money888/LibUpgrade.git
(1)更新庫的使用
第一步,在Application.onCreate方法中進行初始化
@Override
public void onCreate() {
super.onCreate();
LibUpgradeInitializer.init(this);
}
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
第二步,配置更新庫功能
final UpdaterConfiguration config = new UpdaterConfiguration();
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
//此處模擬更新信息獲取,信息獲取後需要將UpdateInfo設置到配置信息中,然後要調用相應的回調方法才能使整個流程完整執行
UpdateInfo updateInfo = new UpdateInfo();
updateInfo.setVersionCode(2);
updateInfo.setVersionName("v1.2");
updateInfo.setUpdateTime("2016/10/28");
updateInfo.setUpdateSize(1024);
updateInfo.setUpdateInfo("更新日誌:\n1.新增萬能更新庫,實現更新功能只要幾行代碼。");
//使用全量更新信息
updateInfo.setUpdateType(UpdateInfo.UpdateType.TOTAL_UPDATE);
UpdateInfo.TotalUpdateInfo totalUpdateInfo = new UpdateInfo.TotalUpdateInfo();
totalUpdateInfo.setApkUrl("http://wap.apk.anzhi.com/data2/apk/201609/05/f06abcb0e2cba4c8ce2301c4b437a492_72932500.ap");
updateInfo.setTotalUpdateInfo(totalUpdateInfo);
if (updateInfo != null) {
//設置更新信息,這樣各模塊就可以通過config.getUpdateInfo()共享這個數據了,注意這個方法一定要調用且要在UpdateCheckCallback.onCheckSuccess之前調用
config.updateInfo(updateInfo);
callback.onCheckSuccess();
} else {
callback.onCheckFail("");
}
}
});
Updater.getInstance().init(config);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
第三步,啓用更新檢查功能
//此處的Context默認必須爲Activity
Updater.getInstance().check(this);
- 1
- 2
- 1
- 2
(2)自定義功能擴展使用
1.增量更新
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
UpdateInfo updateInfo = new UpdateInfo();
//....
//設置增量更新信息,設置完整的apk的MD5及增量包下載地址(此處的增量包需要由bsdiff生成)
updateInfo.setUpdateType(UpdateInfo.UpdateType.INCREMENTAL_UPDATE);
UpdateInfo.IncrementalUpdateInfo incrementalUpdateInfo = new UpdateInfo.IncrementalUpdateInfo();
incrementalUpdateInfo.setFullApkMD5("e7eec01baac70f8a3688570439b9b467");
incrementalUpdateInfo.setPatchUrl("http://bmob-cdn-4990.b0.upaiyun.com/2016/10/28/aa0bc17f40a91b0b80915a49b40c0174.patch");
updateInfo.setIncrementalUpdateInfo(incrementalUpdateInfo);
//.......
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
2.全量更新
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
UpdateInfo updateInfo = new UpdateInfo();
//....
//設置全量更新信息
updateInfo.setUpdateType(UpdateInfo.UpdateType.TOTAL_UPDATE);
UpdateInfo.TotalUpdateInfo totalUpdateInfo = new UpdateInfo.TotalUpdateInfo();
totalUpdateInfo.setApkUrl("http://wap.apk.anzhi.com/data2/apk/201609/05/f06abcb0e2cba4c8ce2301c4b437a492_72932500.apk");
updateInfo.setTotalUpdateInfo(totalUpdateInfo);
//.......
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
3.強制更新
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
UpdateInfo updateInfo = new UpdateInfo();
//....
//設置強制更新
updateInfo.setIsForceInstall(true);
//.......
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
4.普通安裝模式
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
UpdateInfo updateInfo = new UpdateInfo();
//....
//設置普通模式的安裝
updateInfo.setInstallType(UpdateInfo.InstallType.NOTIFY_INSTALL);
//.......
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
5.靜默安裝模式
config.updateChecker(new UpdateChecker() {
@Override
public void check(UpdateCheckCallback callback) {
UpdateInfo updateInfo = new UpdateInfo();
//....
//設置靜默安裝模式,設置此模式前必須確保手機對本應用授予了Root權限
updateInfo.setInstallType(UpdateInfo.InstallType.SILENT_INSTALL);
//.......
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
6.修改更新時的提示UI
//處理UI時,在必要的時機需要調用config.getDownloader()的相關方法,才能保證流程正確執行
config.updateUIHandler(new UpdateCheckUIHandler() {
@Override
public void setContext(Context context) {
//此處的context爲Updater.getInstance().check(Context context)方法傳入的context
}
@Override
public void hasUpdate() {
//有更新時的UI展示
}
@Override
public void noUpdate() {
//沒有更新時的UI展示
}
@Override
public void checkError(String error) {
//更新檢查失敗時的UI展示
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
7.修改文件下載時的UI
config.downloadUIHandler(new DownloadUIHandler() {
@Override
public void setContext(Context context) {
//此處的context爲Updater.getInstance().check(Context context)方法傳入的context
}
@Override
public void downloadStart() {
//開始下載時的UI展示
}
@Override
public void downloadProgress(int progress, int total) {
//下載進度的展示
}
@Override
public void downloadComplete(String path) {
//下載完成時的處理,此處應通過config.getUpdateInfo()獲取更信息,然後再通過相應的安裝器進行安裝
}
@Override
public void downloadError(String error) {
//下載失敗時的UI提示
}
@Override
public void downloadCancel() {
//下載取消時的UI提示
}
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
六、其它二進制差分及合併工具
- 頂
- 2
- 踩
- 0
我的同類文章
- •帶你裝逼帶你飛(將Library發佈到JCenter)2016-11-18
- •庖丁解牛之仿《閃傳》實現文件傳輸(下)2016-04-24
- •庖丁解牛之仿《閃傳》實現文件傳輸(上)2016-04-15
- •Android內存泄漏終極解決篇(下)2016-01-04
- •使用NTP服務器同步Android設備時間2016-06-16
- •庖丁解牛之仿《閃傳》實現文件傳輸(中)2016-04-24
- •WebView使用全攻略2016-01-12
- •Android內存泄漏終極解決篇(上)2015-12-26