热修复技术出来也已经有好长一段时间了,目前比较主流的热修复方案主要有一下几种:
QQ团队基于android dex分包方案提出的热修复方案,代表:Nuwa , Hotfix
Alibaba 提出的热修复方案,代表:AndFix(目前使用最多,兼容问题较严重)
Tecent 提出的热修复方案 代表: tinker (目前性能最优,兼容最好)
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 接入的。
你可以将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'")
}
}
这里如果你们公司的项目不是使用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);
//you must install multiDex whatever tinker is installed!
MultiDex.install(base);
//... 省略
TinkerManager.installTinker(this);
}
@Override
public void onCreate() {
super.onCreate();
//初始化操作 here
}
}
其中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 版本号)*
}
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;
}
//只有在app不需要进行版本升级的时候才会检查补丁版本
if (needAppUpdate = needAppUpdate(mVersionInfo))
{
//showVersionUpdateDialog(); app 跟新dialog弹出 此处省略此操作和Patch 无关,下载文末完整代码查看
} else if (needPatchUpdate(mVersionInfo)) {
// TODO: 2017/2/15 patch download
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)
{
//调用 compareVersion(entity.version_app, info.versionName) 判断是否需要版本更新,此处省略代码
}
/** * 设置差异包版本号 * * @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);
//reset sharePreference patch version code
Log.e(TAG, "App download finished !!!!");
} else
{
Message message = mHandler.obtainMessage();
message.what = MESSAGE_PATCH_OVER;
mHandler.sendMessage(message);
//reset sharePreference patch version code
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) {
//...根据version_type 弹出相应dialog(强制下载dialog 或者建议下载dialog) 省略代码和patch 更新无关,完整代码点击文末连接下载
}
private void showProgressDialog(
DialogInterface.OnClickListener mNegativeListener) {
//...省略代码和patch 更新无关,完整代码点击文末连接下载
}
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;
}
}
public class VersionEntity {
public String app_url; //app 下载地址
public String patch_url; // 补丁包下载地址
public String version_app; //开发最新app版本号
public String version_patch; //最新补丁包版本号
public String remark; //更新提示内容
public String version_type; //1.更新 2.强制更新 3 不更新
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;
}
}
代码有些长,但是比较简单,程序入口为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";//change you ip here
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);
}
}
布局:
<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>
oncreate 中调用更新接口,并将解析后的内容传递给VersionUpdateManager.Handler用于处理三种消息,分别是前面提到的 app,patch ,progress 。
LoadBugClass 类
public class LoadBugClass {
BugClass bugClass;
public LoadBugClass() {
bugClass = new BugClass();
}
public String getBugString() {
return bugClass.bug();
}
}
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 相同就不会进行下载了。