热修复技术出来也已经有好长一段时间了,目前比较主流的热修复方案主要有一下几种:
QQ团队基于android dex分包方案提出的热修复方案,代表:Nuwa , Hotfix
Alibaba 提出的热修复方案,代表:
AndFix(目前使用最多,兼容问题较严重)
Tecent 提出的热修复方案 代表: tinker (目前性能最优,兼容最好)
123
123
blog 上很多大神都对热修复技术做出过自己的分析,我了解的hongyang大神就写过这方面技术分析。链接如下:
http://blog.csdn.net/lmj623565791/article/details/49883661 QQzone分析 http://blog.csdn.net/lmj623565791/article/details/54882693 Tinker 分析
Tinker的实现思想和QQzone类似,本文的重点不在是原理分析上,主要侧重在项目引用上面,顺便带上对Tinker源码的分析,如果你对热修复还不了解的话,强烈建议先去看一下上述推荐的blog。
本篇博客篇幅较长,文末连接进行下载资源。
Tinker项目实战运用
1.Tinker 集成
Tinker 为我们提供了两种方式去集成,一种是命令行接入另外一种是Gradle接入。个人使用的后者,主要是能自动化只需在Terminal执行一下task任务自动编译好补丁包多好啊。第一种接入方式参照上面hongyang 大神的博客,在这主要说一下Gradle接入:github tinker的示例tinker-sample-Android 就是采用gradle 接入的。
https://github.com/Tencent/tinker
你可以将tinker-sample-android 中build.gradle 里面的信息都相应摘到自己项目中,注意是都。以下是一些注意点:
dependencies 依赖的TINKER_VERSION 在项目根目录下gradle.properties下声明着,同时别忘了在根目录下的build.gradle dependencies 添加上 classpath “com.tencent.tinker:tinker-patch-gradle-plugin:${TINKER_VERSION}”
//通过获取git的版本号来获取TinkerId
def gitSha() {
try {
String gitRev =
'git rev-parse --short HEAD'.execute(null, project.rootDir).text.trim()
//
...省略
} catch (Exception e) {
throw new GradleException(
"can't get git rev, you should add git to system path or just input test value, such as 'testTinkerId'")
}
}
123456789
123456789
这里如果你们公司的项目不是使用Git管理的,那么Tinker 在编译生成TinkerId 必然会报错 tinker id is not set !!!。修改为 String gitRev = tinker_id_6235657
项目中的application 将不在是继承Application,按照SampleApplicationLike中的写,采用编译时注解动态生成application。
@SuppressWarnings(
"unused")
@DefaultLifeCycle(application =
"tinker.sample.android.app.SampleApplication",
flags = ShareConstants.TINKER_ENABLE_ALL,
loadVerifyFlag =
false)
public class SampleApplicationLike extends DefaultApplicationLike {
private static final String TAG =
"Tinker.SampleApplicationLike";
public SampleApplicationLike(Application application,
int tinkerFlags,
boolean tinkerLoadVerifyFlag,
long applicationStartElapsedTime,
long applicationStartMillisTime,Intent tinkerResultIntent) {
super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
}
@Override
public void onBaseContextAttached(Context base) {
super.onBaseContextAttached(base);
MultiDex.install(base);
TinkerManager.installTinker(
this);
}
@Override
public void onCreate() {
super.onCreate();
}
}
123456789101112131415161718192021222324252627
123456789101112131415161718192021222324252627
其中DefaultLifeCycle 更改成自己项目的application的路径,经过编译之后会生成SampleApplication命名的applicaiton ,在AndroidMainfest.xml中application name 更改为 android:name=”.app.SampleApplication” flags = Tinker_enable_all,Tinker 默认支持 class library resource 三种修复,所以在没有特殊的情况下就选择enable_all 吧 ! loadVerifyFlags 选择 false 无需修改。至于其他所需要的类都复制到你项目中即可。
加载差异包api
TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),savePath);
2.接口说明
完整成型的app总是少不了更新模块,大部分会把更新请求操作放到MainActivity中,启动app时请求一次接口根据版本号判断是否需要下载app。那么可以在app更新接口中追加上补丁更新相关字段信息。下面这个json信息是我用Tomcat自己写的一个小的web项目中更新接口http://localhost:8080/MySpringWeb/mvc/getVersion返回的。(web写的很烂,在这我只是简单的说明一下需要什么样的字段,后面我会把源发放出,大家测试的时候就用自己公司的服务器接口吧!)
{
"app_name":
"app-debug-0414-14-28-13.apk", *(new app 名字)*
"app_url":
"/MySpringWeb/mvc/getApp", *(app 下载地址)*
"version_type":
"3", *(
1.建议更新
2.强制更新
3 不更新)*
"version_code":
"1.0", *(app 版本号)*
"remark":
"这次我们修复了一些既有的bug,同时增添了一些新的功能.....", *(app 更新提示)*
"patch_name":
"patch_signed_7zip.apk",*(
Patch 补丁包名字)*
"patch_url":
"/MySpringWeb/mvc/getDex", *(
Patch 下载地址)*
"version_patch":
"3.0" *(
Patch 版本号)*
}
123456789
123456789
3.Patch 更新类 VersionUpdateManager
/**
* created by millerJK on time : 2017/4/14
* description : app版本的更新会使用dialog 提示,差异包更新则是后台自动下载,无需使用到dialog
*/
public class VersionUpdateManager {
private static final String TAG =
"VersionUpdateManager";
private static final String SAVE_DIR =
"hotfix";
private static final String PATCH_VERSION_CODE =
"version_patch";
public static final int MESSAGE_UPDATE =
1;
public static final int MESSAGE_APP_OVER =
2;
public static final int MESSAGE_PATCH_OVER =
3;
private String PATCH_NAME;
private String APP_NAME;
private Context mContext;
private VersionEntity mVersionInfo;
private String mRootDir;
private String mSaveApkDirPath;
private String mSavePatchDirPath;
private static VersionUpdateManager mVersionUpdateManager;
private SharedPreferences sp;
private boolean isCancel =
false;
private boolean needAppUpdate =
false;
private AlertDialog.Builder mBuilder;
private Dialog mVersionUpdateDialog;
private ProgressDialog mProgressDialog;
private Handler mHandler;
public static VersionUpdateManager
getInstance(Context context, VersionEntity mVersionInfo, Handler handler) {
if (mVersionUpdateManager ==
null) {
mVersionUpdateManager =
new VersionUpdateManager(context, mVersionInfo, handler);
}
return mVersionUpdateManager;
}
private VersionUpdateManager(Context context, VersionEntity mVersionInfo, Handler handler) {
this.mContext = context;
this.mVersionInfo = mVersionInfo;
this.mHandler = handler;
init();
}
private void init() {
sp = context.getSharedPreferences(PATCH_VERSION_CODE, Context.MODE_PRIVATE);
APP_NAME = mVersionInfo.app_name;
PATCH_NAME = mVersionInfo.patch_name;
createFileSavePath();
}
private void createFileSavePath()
{
if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED))
{
mRootDir = Environment.getExternalStorageDirectory().getAbsolutePath();
mRootDir = mRootDir + File.separator + SAVE_DIR;
mSaveApkDirPath = mRootDir + File.separator +
"apk";
FileUtil.createFolder(mSaveApkDirPath);
mSavePatchDirPath = mRootDir + File.separator +
"patch";
FileUtil.createFolder(mSavePatchDirPath);
}
else
Log.e(TAG,
"sd is not found");
}
public void startTask() {
Log.e(TAG,
"start task");
if (mVersionInfo ==
null)
{
return;
}
if (needAppUpdate = needAppUpdate(mVersionInfo))
{
}
else if (needPatchUpdate(mVersionInfo)) {
Log.e(TAG,
"******** patch need updates required ********");
new Thread(downApkRunnable).start();
}
}
/**
* whether a app version upgrade is required
*
* @param entity
* @return
*/
private boolean needAppUpdate(VersionEntity entity)
{
}
/**
* 设置差异包版本号
*
* @param patchVersionCode
*/
public void setPatchVersionCode(String patchVersionCode) {
sp.edit().putString(PATCH_VERSION_CODE, patchVersionCode).commit();
}
public void clearPatch(){
Tinker.with(context.getApplicationContext()).cleanPatch();
}
/**
* 获取差异包版本号
*
* @return
*/
public String
getPatchVersionCode() {
String patchVersionCode = sp.getString(PATCH_VERSION_CODE,
"0.0");
return patchVersionCode;
}
/**
* whether a patch version upgrade is required
*
* @param entity
* @return
*/
private boolean needPatchUpdate(VersionEntity entity) {
String oldPatchVersion = getPatchVersionCode();
Log.e(TAG,
"oldPatchVersion from local :" + oldPatchVersion);
if (entity ==
null
|| TextUtils.isEmpty(entity.version_patch)
|| TextUtils.isEmpty(oldPatchVersion))
return false;
if (compareVersion(oldPatchVersion, entity.version_patch) >=
0) {
Log.e(TAG,
"******** No patch updates required ********");
return false;
}
else {
Log.e(TAG,
"******** patch updates required ********");
return true;
}
}
String savePath;
/**
* patch and apk version update
*/
private Runnable downApkRunnable =
new Runnable() {
@Override
public void run() {
String fileUrl;
if (needAppUpdate) {
fileUrl = mVersionInfo.app_url;
savePath = mSaveApkDirPath + File.separator + APP_NAME;
Log.e(TAG,
"start downloading APK " + mVersionInfo.app_url);
}
else {
fileUrl = mVersionInfo.patch_url;
savePath = mSavePatchDirPath + File.separator + PATCH_NAME;
Log.e(TAG,
"start downloading Patch " + mVersionInfo.patch_url);
}
Log.e(TAG, savePath);
try {
URL url =
new URL(fileUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.connect();
conn.setConnectTimeout(
5000);
conn.setReadTimeout(
5000);
int length = conn.getContentLength();
InputStream is = conn.getInputStream();
File ApkFile =
new File(savePath);
FileOutputStream fos =
new FileOutputStream(ApkFile);
int count =
0;
byte[] buf =
new byte[
1024 *
5];
do {
int numread = is.read(buf);
count += numread;
int progress = (
int) (((
float) count / length) *
100);
sendProgressMessage(progress);
Log.e(TAG,
"downloading ..." + count +
"/" + length +
" " + progress +
"%");
if (numread <=
0) {
if (needAppUpdate)
{
mProgressDialog.dismiss();
mHandler.sendEmptyMessage(MESSAGE_APP_OVER);
Log.e(TAG,
"App download finished !!!!");
}
else
{
Message message = mHandler.obtainMessage();
message.what = MESSAGE_PATCH_OVER;
mHandler.sendMessage(message);
Log.e(TAG,
"Patch download finished !!!!");
}
break;
}
fos.write(buf,
0, numread);
fos.flush();
}
while (!isCancel);
fos.close();
is.close();
}
catch (MalformedURLException e) {
e.printStackTrace();
}
catch (IOException e) {
e.printStackTrace();
}
}
};
private void sendProgressMessage(
int progress) {
Message message = mHandler.obtainMessage();
message.what = MESSAGE_UPDATE;
message.obj = progress;
mHandler.sendMessage(message);
}
private void showVersionUpdateDialog(
DialogInterface.OnClickListener mPositionListener,
DialogInterface.OnClickListener mNegativeListener) {
}
private void showProgressDialog(
DialogInterface.OnClickListener mNegativeListener) {
}
public void setProgress(
int progress) {
mProgressDialog.setProgress(progress);
}
/**
* app 安装
*/
public void startInstall() {
installApk(mSaveApkDirPath + File.separator + APP_NAME);
}
/**
* Patch 安装
*/
public void upgradePatch(){
TinkerInstaller.onReceiveUpgradePatch(context.getApplicationContext(),savePath);
Log.e(TAG,
"newPatchVersion to local:" + mVersionInfo.version_patch);
setPatchVersionCode(mVersionInfo.version_patch);
}
private void installApk(String saveFileName) {
setPatchVersionCode(
"0.0");
clearPatch();
File apkfile =
new File(saveFileName);
if (!apkfile.exists()) {
return;
}
try {
unInstall();
install(apkfile);
}
catch (Exception e) {
e.printStackTrace();
}
}
/**
* uninstall the original application first
*/
private void unInstall() {
Uri uri = Uri.parse(
"package:" + mContext.getPackageName());
Intent deleteIntent =
new Intent();
deleteIntent.setType(Intent.ACTION_DELETE);
deleteIntent.setData(uri);
mContext.startActivity(deleteIntent);
}
/**
* install the new application second
*/
private void install(File apkfile) {
Intent i =
new Intent(Intent.ACTION_VIEW);
i.setDataAndType(Uri.parse(
"file://" + apkfile.toString()),
"application/vnd.android.package-archive");
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mContext.startActivity(i);
}
public int compareVersion(String remoteVersion, String localVersion) {
return diff;
}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
public class VersionEntity {
public String app_url;
public String patch_url;
public String version_app;
public String version_patch;
public String remark;
public String version_type;
public String app_name;
public String patch_name;
public VersionEntity() {
}
public VersionEntity(String app_url, String patch_url, String version_app, String version_patch
, String remark, String version_type, String app_name, String patch_name) {
this.app_url = app_url;
this.patch_url = patch_url;
this.version_app = version_app;
this.version_patch = version_patch;
this.remark = remark;
this.version_type = version_type;
this.app_name = app_name;
this.patch_name = patch_name;
}
}
123456789101112131415161718192021222324252627
123456789101112131415161718192021222324252627
代码有些长,但是比较简单,程序入口为startTask(),这方法里面有写一句话 “只有在app 不需要版本升级的时候才会检测补丁是否需更新。”其实想想就知道为什么,每次版本升级必定是老版本bug都修复了,同时有可能添加了一些新功能,我们可以认为最新的app是没有bug的,所以根本就不需要进行补丁检测判断。通过needAppUpdate() 判断app 版本是否需要进行版本更新,needPatchUpdate() 判断补丁是否需要进行升级,其中app版本升级那个分支就不看了不是重点。着重看一下补丁升级分支。
通过sharedPerence 获取保存在本地的patch版本号(初始version = 0)和 VersionEntity中version_patch做比对判断,返回true则开启线程执行下载 runnable ,看一下202-227行,会发送三种Message 给主线程 1. Progress 更新进度 2.APP 下载完毕 3. Patch 下载完毕, 如果App下载完毕则需要调用 installApk方法,首先会执行 setPatchVersionCode(“0.0”); 方法将本地patchVersion 重置,然后执行clearPatch();将 /data/data/com.xxx.xxxx/tinker 删除掉。 如果是 Patch下载完毕则需要调用upgradePatch()方法,同时更新本地Patch 版本保存到SharePerference中。
5.测试
MainActivity 代码:
public class MainActivity extends AppCompatActivity {
private static final String TAG =
"Tinker.MainActivity";
public static final String BASE_URL =
"http://172.27.35.1:8080";
private VersionUpdateManager mVersionUpdateManager;
private VersionEntity mEntity;
private Button mButton;
private Handler mHandler =
new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
switch (msg.what) {
case VersionUpdateManager.MESSAGE_APP_OVER:
mVersionUpdateManager.startInstall();
break;
case VersionUpdateManager.MESSAGE_PATCH_OVER:
mVersionUpdateManager.upgradePatch();
break;
case VersionUpdateManager.MESSAGE_UPDATE:
break;
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mButton = (Button) findViewById(R.id.showInfo);
mButton.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View v) {
LoadBugClass referenceClass =
new LoadBugClass();
Toast.makeText(MainActivity.
this, referenceClass.getBugString(), Toast.LENGTH_LONG).show();
}
});
checkUpdate();
}
private void checkUpdate() {
String url = BASE_URL +
"/MySpringWeb/mvc/getVersion";
Log.e(
"check update url", url);
OkHttpUtils
.get()
.url(url)
.build()
.execute(
new StringCallback() {
@Override
public void onError(Call call, Exception e,
int id) {
}
@Override
public void onResponse(String response,
int id) {
Log.e(
"json from server", response);
dealData(response);
}
});
}
private void dealData(String response) {
try {
JSONObject jsonObject =
new JSONObject(response);
String version_code = jsonObject.getString(
"version_code");
String version_patch = jsonObject.getString(
"version_patch");
String remark = jsonObject.getString(
"remark");
String version_type = jsonObject.getString(
"version_type");
String app_url = BASE_URL + jsonObject.getString(
"app_url");
String patch_url = BASE_URL + jsonObject.getString(
"patch_url");
String app_name = jsonObject.getString(
"app_name");
String patch_name = jsonObject.getString(
"patch_name");
mEntity =
new VersionEntity(app_url, patch_url, version_code
, version_patch, remark, version_type,app_name,patch_name);
Log.e(
"append url with ip", mEntity.toString());
mVersionUpdateManager = VersionUpdateManager.getInstance(MainActivity.
this, mEntity, mHandler);
mVersionUpdateManager.startTask();
}
catch (JSONException e) {
e.printStackTrace();
}
}
@Override
protected void onResume() {
Log.e(TAG,
"i am on onResume");
super.onResume();
Utils.setBackground(
false);
}
@Override
protected void onPause() {
super.onPause();
Utils.setBackground(
true);
}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
布局:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".app.MainActivity">
<Button
android:id="@+id/showInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:text="show info"/>
</RelativeLayout>
1234567891011121314
1234567891011121314
oncreate 中调用更新接口,并将解析后的内容传递给VersionUpdateManager.Handler用于处理三种消息,分别是前面提到的 app,patch ,progress 。
LoadBugClass 类
public class LoadBugClass {
BugClass bugClass;
public LoadBugClass() {
bugClass =
new BugClass();
}
public String
getBugString() {
return bugClass.bug();
}
}
123456789101112
123456789101112
BugClass类
public class BugClass {
public BugClass() {
}
public String bug() {
return " bug......";
}
BugClass 为 bug类,bug 返回值为bug….模拟 bug 返回值为fix …..模拟bugClass中的bug被修复。
1. 生成bug Apk
首先我们先build 编一个有bug的apk 包,为了方便测试我编的是Debug包,对于Debug包我同样进行了混淆,编译完毕之后在项目app/build/bakApk 中即可以找到编译生成的包
然后我们修改Web项目中更新接口version_patch 字段为0.0
将这个有bug的apk 包按照到自己的手机上,查看效果 点击 showInfo 弹出toast 信息 bug………
2. 生成patch Apk
复制bug apk名字,对项目中app下 build.gradle中tinkeroldApkPath,tinkerApplyMappingPath,tinkerApplyResourcePath 进行修改 如下图:
Tinker 是通过差异比较生成的Dex apk 所以old 包路径必须的先设置一下,这样才能自动化生成差异包。
然后我们修改 BugClass的 bug 方法 返回为fix …… 模拟 bug 已经进行修复。执行生成差异包命令:
./gradlew tinkerPatchRelease // 或者 ./gradlew tinkerPatchDebug
因为我是使用Debug编译的,所以在 Terminal中执行 gradlew tinkerPatchDebug
只有显示BUILD SUCCESSFUL 才算是生成差异包完成,差异包路径在/app/build/outputs/tinkerPatch/debug/下
其中patch_signed_7zip.apk 就是补丁apk,复制apk 放到服务器然后将补丁号版本修改为1.0,重新运行app.
SampleResultService中可根据需求进行自定义操作,比如在弹出patch success之后代码重启等……。
最后附送 app首次启动 和 再次启动 输出的日志
可以看到再次启动,判断Patch Version 相同就不会进行下载了。