一台机器上Github/Gitlab多账户管理SSH Key切换解决push冲突

问题产生场景


无密码与远程服务器交互的秘密 – SSH

如果采用ssh 协议或者git 协议通过终端命令对远程仓库进行push操作的时候,大概的过程如下:(前提在 Github 上已经配置的本机的 SSH Public Key)

  1. 客户端发起一个 Public Key 的认证请求,并发送RSA Key的模数作为标识符。(关于 RSA Key 详细 维基百科
  2. 服务端检查是否存在请求帐号的公钥(Linux中存储在~/.ssh/authorized_keys文件中),以及其拥有的访问权限。
  3. 服务端使用对应的公钥对一个随机的256位的字符串进行加密,并发送给客户端。
  4. 客户端使用私钥对字符串进行解密,并将其结合session id生成一个MD5值发送给服务端。 结合session id的目的是为了避免攻击者采用重放攻击(replay attack)。
  5. 服务端采用同样的方式生成MD5值与客户端返回的MD5值进行比较,完成对客户端的认证。
  6. 将push的内容进行加密与服务端传输数据。

关于 SSH,请查看 SSH原理简介 ,更通俗易懂的文章请查看阮一峰-SSH原理与运用(一):远程登录 。

具体场景

无论使用哪种代码托管服务商,对于 Git 而言,邮箱 是识别用户的唯一手段,所以对于不同的服务商,由于邮箱不同,那么通过邮件名创建的 SSH Key 自然是不同的,这时候在不同的服务商之间进行 push 命令的时候,Git 是不知道使用哪个 SSH Key ,自然导致 push 的失败。场景如下:

  1. 在公司团队使用搭建的 Gitlab 服务,提交邮箱xirong.liu@corp.xx.com, 个人 Github 服务,提交邮箱 ixirong.liu@gmail.com(Bitbucket 同理)。
  2. 有两个Github账户,不同的账户提交不同的仓库内容。

解决方案

方案一:同一个邮箱

由于邮箱是识别的唯一手段,那么自然的,这两者采用同一个邮箱,生成的 public key 也会是同一个,上传到 Github 或者 Gitlab 上面,在 Git 的配置中 ,设置好 Global 的配置 :git config --global user.name 'xirong.liu' && git config --global user.email 'xirong.liu@corp.xx.com' 进行日常的开发是没有问题的。

实际生活中采用同一个邮箱的可能性并不是太大,这就引出了方案二

方案二:基于config文件

所谓的方案二,原理上就是对 SSH 协议配置 config 文件,对不同的域名采用不同的认证密钥。

git config 介绍

Git有一个工具被称为git config,它允许你获得和设置配置变量;这些变量可以控制Git的外观和操作的各个方面。这些变量可以被存储在三个不同的位置:

  1. /etc/gitconfig 文件:包含了适用于系统所有用户和所有库的值。如果你传递参数选项’--system’ 给 git config,它将明确的读和写这个文件。
  2. ~/.gitconfig 文件 :具体到你的用户。你可以通过传递 ‘--global’ 选项使Git 读或写这个特定的文件。
  3. 位于 Git 目录的 config 文件 (也就是 .git/config) :无论你当前在用的库是什么,特定指向该单一的库。每个级别重写前一个级别的值。因此,在 .git/config 中的值覆盖了在/etc/gitconfig中的同一个值,可以通过传递‘--local’选项使Git 读或写这个特定的文件。

由于采用了不同的邮箱,对不同的服务商进行提交,所以此时我们经常配置的 git config --global 就不能常用了,必须在每个仓库的目录下进行配置自己的用户名、邮箱。(嫌麻烦?xirong 是这么解决的,由于个人的 Github 上有较多的仓库,而自己团队的代码基本上都是稳定的,有数的几个,所以在 git config --global user.email 'ixirong.liu@gmail.com' 中全局配置的是个人邮箱,在团队的项目中配置)

1. 配置 Git 用户名、邮箱

如刚才所说,xirong 的配置如下:

1
2
3
4
5
# 全局配置,Github仓库中默认使用此配置
git config –global user.name ‘xirong’ && git config –global user.email ‘ixirong.liu@gmail.com’
# 团队项目配置,每次新创建一个项目,需要执行下
git config —local user.name ‘xirong.liu’ && git config —local user.email ‘xirong.liu@corp.xxx.com’

2. 生成 ssh key 上传到 Github/Gitlab

ssh key 默认生成后保存在 ~/.ssh/目录下 ,默认为 id_rsa 和 id_rsa.pub 两个文件,由于我们需要分开配置,所以这么做:

1
2
3
4
5
# 生成公钥、密钥的同时指定文件名,Gitlab使用
ssh-keygen -t rsa -f ~/.ssh/id_rsa.gitlab -C “xirong.liu@corp.xxx.com”
# 生成默认,Github使用
ssh-keygen -t rsa -C “ixirong.liu@gmail.com”

命令执行完成后,这时~/.ssh目录下会多出id_rsa.gitlabid_rsa.gitlab.pub两个文件,id_rsa.gitlab.pub 里保存的就是我们要使用的key,这个key就是用来上传到 Gitlab上的。

3. 配置 config 文件

在 ~/.ssh目录下,如果不存在,则新建 touch ~/.ssh/config文件 ,文件内容添加如下:

1
2
3
Host *.corp.xxx.com
IdentityFile ~/.ssh/id_rsa.gitlab
User xirong.liu

配置完成后,符合 *.corp.xxx.com后缀的 Git 仓库,均采取~/.ssh/id_rsa.gitlab 密钥进行验证,其它的采取默认的。

4. 上传public key 到 Github/Gitlab

以Github为例,过程如下:

  1. 登录github
  2. 点击右上方的Accounting settings图标
  3. 选择 SSH key
  4. 点击 Add SSH key

在出现的界面中填写SSH key的名称,填一个你自己喜欢的名称即可,然后将上面拷贝的~/.ssh/id_rsa.pub文件内容粘帖到key一栏,在点击“add key”按钮就可以了。

添加过程github会提示你输入一次你的github密码 ,确认后即添加完毕。 上传Gitlab的过程一样,请自己操作。

5. 验证是否OK

由于每个托管商的仓库都有唯一的后缀,比如 Github的是 git@github.com:*,所以可以这样测试:

1
2
3
4
➜ ~ ssh -T git@github.com
Hi xirong! You‘ve successfully authenticated, but GitHub does not provide shell access.
➜ ~ ssh -T git@gitlab.dev
Welcome to GitLab, xirong.liu!

看到这些 Welcome 信息,说明就是 OK的了。

以后,如果还有任何的需求,都可以这么解决,看下 xirong 的几个托管仓库:

1
2
3
4
5
6
7
8
9
10
➜ ~ ll ~/.ssh
total 40
-rw-r–r– 1 xirong staff 264 Jul 10 14:42 config
-rw——- 1 xirong staff 3243 Jul 10 14:09 id_rsa
-rw——- 1 xirong staff 1675 Jan 28 20:39 id_rsa.gitlab
-rw-r–r– 1 xirong staff 407 Jan 28 20:39 id_rsa.gitlab.pub
-rw-r–r– 1 xirong staff 747 Jul 10 14:09 id_rsa.pub
-rw——- 1 xirong staff 1679 Jun 22 11:42 id_rsa_gitcafe
-rw-r–r– 1 xirong staff 407 Jun 22 11:42 id_rsa_gitcafe.pub
-rw-r–r– 1 xirong staff 9139 Jul 29 15:08 known_hosts

客户端调起方案解析(scheme、intent uri、Universal Links)

移动互联时代,很多互联网服务都会同时具备网站以及移动客户端,很多人认为APP的能帮助建立更稳固的用户关系,于是经常会接到各种从浏览器、webview、短信、甚至是在其他APP中唤醒APP的运营需求。

运营推广场景

  • 微信、QQ等 -> 唤醒APP
    用户通过某APP分享了一条链接至微信或QQ,用户B点开该链接后,会引导用户B打开该APP或者下载该APP。
  • 浏览器 -> 唤醒APP
    用户A通过浏览器打开了某APP的M站或者官网,如果检测到A来自手机端,则会引导用户打开该APP或者下载该APP。
  • 短信、邮件、二维码等 -> 唤醒APP
    用户A打开了某APP的推广短信,邮件或者扫描二维码等,会引导用户打开该APP或者下载该APP。
  • 其他APP -> 唤醒APP
    用户A通过第三方APP分享了(任何可以分享信息的品台或工具:IM或者短信等)一条链接至用户B,用户B点开该链接后,链接会引导用户B打开指定APP或者下载指定APP。

APP服务化理念

所谓APP的服务化就是利用唤醒功能将APP的特定页面做为一个单独的服务或者内容,通过一定的渠道和载体传播出去,并且能够像传统的网页链接那样被一键唤醒。

更多关于APP服务化理念,推荐大家看看这篇文章

那么移动平台提供了哪些唤醒APP的方法呢?

如何唤醒APP

目前常见的唤醒APP方式有几种:

  • URL Scheme

URL Scheme是iOS,Android平台都支持,只需要原生APP开发时注册scheme, 那么用户点击到此类链接时,会自动唤醒APP,借助于URL Router机制,则还可以跳转至指定页面。比如:

<!-- 唤醒APP并跳转至指定的path页面 -->
<a href="<scheme>://<path>?<params>=<value>">打开APP</a>

<!-- JS设置iframe src跳转至指定的path页面 -->
//创建一个隐藏的iframe
var ifr = document.createElement('iframe');
ifr.src = '<scheme>://<path>?<params>=<value>';
ifr.style.display = 'none';
document.body.appendChild(ifr);
//记录唤醒时间
var openTime = +new Date();
window.setTimeout(function(){
    document.body.removeChild(ifr);
    //如果setTimeout 回调超过2500ms,则弹出下载
    if( (+new Date()) - openTime > 2500 ){
        window.location = '指定的下载页面';
    }
},2000)

这种方式是当期使用最广泛,也是最简单的,但是需要手机,APP支持URL Scheme
优点: 开发成本低,绝大多数都支持,web-native协议制定也简单。
缺点: 错误处理情况因平台不同,难以统一处理,部分APP会直接跳错误页(比如Android Chrome/41,iOS中老版的Lofter);也有的停留在原页面,但弹出提示“无法打开网页”(比如iOS7);iOS8以及最新的Android Chrome/43 目前都是直接停留在当前页,不会跳出错误提示。
支持情况: iOS在实际使用中,腾讯系的微信,QQ明确禁止使用,iOS9以后Safari不再支持通过js,iframe等来触发scheme跳转,并且还加入了确认机制,使得通过js超时机制来自动唤醒APP的方式基本不可用;Android平台则各个app厂商差异很大,比如Chrome从25及以后就同Safari情况一样。

  • Android intent

这是Android平台独有的,使用方式如下:

intent:
HOST/URI-path // Optional host 
#Intent; 
  package=[string]; 
  action=[string]; 
  category=[string]; 
  component=[string]; 
  scheme=[string]; 
end;

这里的HOST/URI-path, 与普通http URL 的host/path书写方式相同, package是Android APP的包名,其它参数如action、category、component不是很理解, 有兴趣可以去了解官方文档。代码如下:

<!-- 唤醒APP并跳转至指定的path页面 -->
<a href="intent://<role>/<path>#Intent;scheme=<scheme>;package=com. domain;end"">打开APP</a>

如果手机能匹配到相应的APP,则会直接打开;如没有安装,则会跳到手机默认的应用商店,比如Google原生系统Nexus 5,将会直接跳到Google Play,对于国内各厂商定制过的系统,则跳转到各自的默认应用商店,或者弹出商店供选择。intentscheme相对完善的一点是,提供一个打开失败去向URL的选项,可以通过指定参数S.browser_fallback_url来指定去向URL。比如打开APP动作,如果打开失败,则跳转到APP下载页,这对于国内的特殊网络环境,还是挺有用的。

  • Safari内置APP广告条

在页面Head中增加如下meta, 添加智能App广告条,可以自动判断是否已安装应用,只能用于Safari,在第三方应用中就不行了。

<meta"apple-itunes-app"content"app-id=myAppStoreID, affiliate-data=myAffiliateData, app-argument=myURL"
  • Android Chrome内置APP安装提示

这个是Mobile Chrome 43 beta新加入的特性,在用户浏览某一个网站多次后,如果Chrome发现该站点有原生APP,则会提示用户下载原生APP,此项特性开发者无法干预,完全是Google的推荐行为。

  • Universal Links

在2015年的WWDC大会上,Apple推出了iOS 9的一个功能:Universal Links通用链接。如果你的App支持Universal Links,那就可以访问HTTP/HTTPS链接直接唤起APP进入具体页面,不需要其他额外判断;如果未安装App,访问此通用链接时,可以一个自定义网页。

优点:

  • 唯一性:不像自定义的scheme,因为它使用标准的HTTP/HTTPS链接到你的web站点,所以它不会被其它的app所声明.另外,Custom URL scheme 因为是自定义的协议,所以在没有安装 app 的情况下是无法直接打开的,而Universal Links本身是一个HTTP/HTTPS链接,所以有更好的兼容性;
  • 安全:当用户的手机上安装了你的app,那么iOS将去你的网站上去下载你上传上去的说明文件(这个说明文件声明了APP可以打开哪些类型的http链接)。因为只有你自己才能上传文件到你网站的根目录,所以你的网站和你的app之间的关联是安全的;
  • 可变:当用户手机上没有安装你的app的时候,Universal Links也能够工作。如果你愿意,在没有安装APP的时候,用户点击链接,会在safari中展示你网站的内容;
  • 简单:一个URL链接,可以同时作用于网站和app,可以定义统一的web-native协议;
  • 私有:其它APP可以在不需要知道是否安装了的情况下和你的APP相互通信;

缺点:

  • 只支持iOS9及以上系统;当使用Universal Link打开APP之后,状态栏右上角会出现链接地址,点击它会取消Universal Link,需引导用户重新使用Safari再次打开该链接,弹出Safari内置APP广告条,再点击打开重新开启Universal Link。

iOS9开启Universal Links

首先,你必须有一个域名,且这个域名的网站需要支持https,然后拥有网站的上传到.well-known目录的权限(这个权限是为了上传一个Apple指定的文件apple-app-site-association),有了这个先决条件才能够继续下面的步骤:

  • 创建一个json格式的命名为apple-app-site-association文件,注意这个文件必须没有后缀名,文件名必须为`apple-app-site-association!!!
{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "9JA89QQLNQ.com.apple.wwdc", 
                "paths": [ "/wwdc/news/", "/videos/wwdc/2015/*"]
            },
            {
                "appID": "ABCD1234.com.apple.wwdc", 
                "paths": [ "*" ]
            }
        ]
    }
}

说明: appID = teamId.yourapp’s bundle identifier
paths = APP支持的路径列表,只有这些指定的路径的链接,才能被APP所处理,大小写敏感。举个例子,如果你的网站是www.domain.com,你的path写的是”/support/*”,那么当用户点击www.domain.com/support/<path>?<params>=<value>,就可以唤醒APP了,相反www.domain.com/other就不会。此外Apple为了方便开发者,提供了一个网址来验证我们编写的这个apple-app-site-association是否合法有效。

  • 激活Xcode工程中的Associated Domains能力,在其中的Domains中填入你想支持的域名(这里不是随便填的,是可以支持你需要的Universal Links的域名), 必须以applinks:为前缀,例如:applinks:www.domain.comApple将会在合适的时候,从这个域名请求apple-app-site-association文件。注意:当你打开Associated Domains后,Xcode会在你的工程中添加.entitlements文件,并且登录开发者中心,可以看到Associated Domains处于Enable状态。
  • AppDelegate里实现如下代理方法:
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray *))restorationHandler {
    // NSUserActivityTypeBrowsingWeb 由Universal Links唤醒的APP
    if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {
        NSURL *webpageURL = userActivity.webpageURL;
        NSString *host = webpageURL.host;
        if ([host isEqualToString:@"yohunl.com"]) {
            //进行我们需要的处理
        } else {
            [[UIApplication sharedApplication]openURL:webpageURL];
        }
    }
    return YES;
}

至此APP已经开启Universal Links,可以通过链接唤醒APP,并跳转至指定页面了。

  • Android App Links

在2015年的Google I/O大会上,Android M宣布了一个新特性:App Links让用户在点击一个普通web链接的时候可以打开指定APP的指定页面,前提是这个APP已经安装并且经过了验证,否则会显示一个打开确认选项的弹出框。在推动deep linking上Google和Apple可谓英雄所见略同,优缺点也大致相同,只支持Android M以上系统。

Android M开启Universal Links

开启Android App Links的方式也大致同iOS一致:

先决条件:
  1. 注册一个域名
  2. 域名的SSL通道
  3. 具有上传JSON文件到域名的能力
  4. Android Studio 1.3 Preview 及以上
  5. Gradle 版本 — com.android.tools.build:gradle:1.3.0-beta3 及以上
  6. 设置 compileSdkVersion 为 android-MNC 及以上
  7. buildToolsVersion — 23.0.0 rc2 及以上
  8. 创建一个json格式的web-app关联文件,如assetlinks.json,上传到web端
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "example.com.puppies.app",
    "sha256_cert_fingerprints":
    ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
  }
  },
  {
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "example.com.monkeys.app",
    "sha256_cert_fingerprints":
    ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
  }
}]

其中
package_name: manifest中声明的包名。
sha256_cert_fingerprints: 可以使用如下命令生成APP的sha256指纹签名

// keystore中持有app release keys的app路径。
// 这个路径依赖于项目设置,因此不同的app是不同的。
keytool -list -v -keystore my-release-key.keystore

上传这个文件到服务器的.well-known/assetlinks.json,为了避免今后每个app链接请求都访问网络,安卓只会在app安装的时候检查这个文件。

  • 创建一个处理App Links的activity,这个activity的目的是为了实现一种这样的机制:负责捕获与解析深度链接,同时转发用户到正确的视图。同时配置激活App Links能力,如下所示:
<activity
  android:name="com.your.app.activity.ParseDeepLinkActivity"
  android:alwaysRetainTaskState="true"
  android:launchMode="singleTask"
  android:noHistory="true"
  android:theme="@android:style/Theme.Translucent.NoTitleBar">

  // 此处激活 App Links
  <intent-filter android:autoVerify="true">
      // 注意yourdomain.com 与 www.yourdomain.com 被看成两个不同的域名,因此你需要为每个域名添加一对http和https
    <data android:scheme="http" android:host="yourdomain.com" />
    <data android:scheme="https" android:host="yourdomain.com" />
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
  </intent-filter>
</activity>
  • 实现App Linksactivity的处理逻辑
public class ParseDeepLinkActivity extends Activity {
  public static final String PRODUCTS_DEEP_LINK = "/products";
  public static final String XMAS_DEEP_LINK = "/campaigns/xmas";

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    // Extrapolates the deeplink data
    Intent intent = getIntent();
    Uri deeplink = intent.getData();

    // Parse the deeplink and take the adequate action 
    if (deeplink != null) {
      parseDeepLink(deeplink);
    }
  }

  private void parseDeepLink(Uri deeplink) {
    // The path of the deep link, e.g. '/products/123?coupon=save90'
    String path = deeplink.getPath();

    if (path.startsWith(PRODUCTS_DEEP_LINK)) {
      // Handles a product deep link
      Intent intent = new Intent(this, ProductActivity.class);
      intent.putExtra("id", deeplink.getLastPathSegment()); // 123
      intent.putExtra("coupon", deeplink.getQueryParameter("coupon")); // save90
      startActivity(intent);
    } else if (XMAS_DEEP_LINK.equals(path)) {
      // Handles a special xmas deep link
      startActivity(new Intent(this, XmasCampaign.class));
    }  else {
      // Fall back to the main activity
      startActivity(new Intent(context, MainActivity.class));
    }
  }
}

至此APP已经开启App Links,可以通过链接唤醒APP,并跳转至指定页面了。

后记

总结以上各种方案,唤醒能力似乎都不是很完美,从长远技术趋势来看都是Deep Links,都需要

  • 一个支持HTTPS的web站

但面对移动互联网浪潮中海量APP的唤醒能力需求,一定会有创业公司来做这件事,比如国外的HoKoLinks,国内的魔窗,是自己造轮子,还是用轮子,各有利弊。

cmd、命令行、终端签名apk

先cd到待签名的apk目录下,将签名文件也放入相同目录,然后执行以下命令,mac系统天然支持。

 

jarsigner -verbose -keystore android123.keystore -signedjar android123_signed.apk android123.apk android123.keystore

微信小程序架构解析,工作原理解析

作者介绍:

渠宏伟,腾讯高级工程师,从事Web前端开发5年,先后负责企鹅电竞、腾讯视频VIP、腾讯OA开发框架、腾讯微信HR助手等项目。对Web前端架构、.NET架构有丰富的经验。

| 导语 微信小程序的公测掀起了学习小程序开发的浪潮,天生跨平台,即用即走、媲美原生体验、完善的文档、高效的开发框架,小程序给开发者带来了很多惊喜。通过这篇文章和大家一起分析小程序的架构,分享开发经验。

一、小程序介绍

1、小程序特点

微信小程序原理解析

2、小程序演示


视频地址:https://v.qq.com/x/page/w0353d7co6y.html

3、小程序为什么那么快

微信小程序原理解析
Page Frame

Native预先额外加载一个WebView
当打开指定页面时,用默认数据直接渲染,请求数据回来时局部更新
返回显示历史View
退出小程序,View状态不销毁

4、小程序入口

微信小程序原理解析

扫码进入小程序

搜索小程序

小程序发送到桌面(Android)

发送给朋友

二、小程序架构

微信小程序的框架包含两部分View视图层、App Service逻辑层,View层用来渲染页面结构,AppService层用来逻辑处理、数据请求、接口调用,它们在两个线程里运行。

视图层使用WebView渲染,逻辑层使用JSCore运行。

视图层和逻辑层通过系统层的JSBridage进行通信,逻辑层把数据变化通知到视图层,触发视图层页面更新,视图层把触发的事件通知到逻辑层进行业务处理。

微信小程序原理解析

 

小程序启动时会从CDN下载小程序的完整包

微信小程序原理解析

 

三、View (页面视图)

视图层由 WXML 与 WXSS 编写,由组件来进行展示。

将逻辑层的数据反应成视图,同时将视图层的事件发送给逻辑层。

1、View – WXML

WXML(WeiXin Markup Language)

支持数据绑定

支持逻辑算术、运算

支持模板、引用

支持添加事件(bindtap)

微信小程序原理解析

wxml编译器:wcc  把wxml文件 转为 js   执行方式:wcc index.wxml

2、View – WXSS

WXSS(WeiXin Style Sheets)

支持大部分CSS特性

添加尺寸单位rpx,可根据屏幕宽度自适应

使用@import语句可以导入外联样式表

不支持多层选择器-避免被组件内结构破坏

微信小程序原理解析

wxss编译器:wcsc 把wxss文件转化为 js 执行方式: wcsc index.wxss

3、View – WXSS Selectors

WXSS目前支持如下选择器:

微信小程序原理解析
4、View – Component

小程序提供了一系列组件用于开发业务功能,按照功能与HTML5的标签进行对比如下:

微信小程序原理解析

小程序的组件基于Web Component标准

使用Polymer框架实现Web Component

微信小程序原理解析

 

5、View – Native Component

目前Native实现的组件有 <canvas/> <video/> <map/> <textarea/>

Native组件层在WebView层之上

微信小程序原理解析

 

四、App Service(逻辑层)

逻辑层将数据进行处理后发送给视图层,同时接受视图层的事件反馈

1、App( ) 小程序的入口;Page( ) 页面的入口

3、提供丰富的 API,如微信用户数据,扫一扫,支付等微信特有能力。

4、每个页面有独立的作用域,并提供模块化能力。

5、数据绑定、事件分发、生命周期管理、路由管理

运行环境

IOS – JSCore

Android – X5 JS解析器

DevTool – nwjs Chrome 内核

1、App Service – Binding

数据绑定使用 Mustache 语法(双大括号)将变量包起来,动态数据均来自对应 Page 的 data,可以通过setData方法修改数据。
事件绑定的写法同组件的属性,以 key、value 的形式,key 以bind或catch开头,然后跟上事件的类型,如bindtap, catchtouchstart,value 是一个字符串,需要在对应的 Page 中定义同名的函数。

微信小程序原理解析

微信小程序原理解析

 

2、App Service – Life Cylce

微信小程序原理解析

 

3、App Service – API

API通过JSBridge和Native 进行通信

微信小程序原理解析

 

4、App Service – Router

navigateTo(OBJECT)

保留当前页面,跳转到应用内的某个页面,使用navigateBack可以返回到原页面。页面路径只能是五层

redirectTo(OBJECT)

关闭当前页面,跳转到应用内的某个页面。

navigateBack(OBJECT)

关闭当前页面,返回上一页面或多级页面。可通过 getCurrentPages()) 获取当前的页面栈,决定需要返回几层。

 

五、小程序开发经验

1、小程序存在的问题

小程序仍然使用WebView渲染,并非原生渲染
需要独立开发,不能在非微信环境运行。
开发者不可以扩展新组件。
服务端接口返回的头无法执行,比如:Set-Cookie。
依赖浏览器环境的js库不能使用,因为是JSCore执行的,没有window、document对象。
WXSS中无法使用本地(图片、字体等)。
WXSS转化成js 而不是css,为了兼容rpx。
WXSS不支持级联选择器。
小程序无法打开页面,无法拉起APP。

小程序不能和公众号重名,于是小程序的名字就成了:自选股+、滴滴出行DiDi 。

 

2、小程序可以借鉴的优点

提前新建WebView,准备新页面渲染。
View层和逻辑层分离,通过数据驱动,不直接操作DOM。
使用Virtual DOM,进行局部更新。
全部使用https,确保传输中安全。
使用离线能力。
前端组件化开发。
加入rpx单位,隔离设备尺寸,方便开发。

 

3、脱离微信的“小程序”:PWA 渐进式应用

PWA 全称是 Progressive Web Apps ,译成中文就是渐进式应用,是 Google 在 2015 年 6 月 15 日提出的概念。
Progressive Web Apps 是结合了 web 和 原生应用中最好功能的一种体验。对于首次访问的用户它是非常有利的, 用户可以直接在浏览器中进行访问,不需要安装应用。随着时间的推移当用户渐渐地和应用建立了联系,它将变得越来越强大。它能够快速地加载,即使在弱网络环境下,能够推送相关消息, 也可以像原生应用那样添加至主屏,能够有全屏浏览的体验。

微信小程序原理解析
PWA具有如下特点:

渐进增强 – 支持的新特性的浏览器获得更好的体验,不支持的保持原来的体验。
离线访问 – 通过 service workers 可以在离线或者网速差的环境下工作。
类原生应用 – 使用app shell model做到原生应用般的体验。
可安装 – 允许用户保留对他们有用的应用在主屏幕上,不需要通过应用商店。
容易分享 – 通过 URL 可以轻松分享应用。
持续更新 – 受益于 service worker 的更新进程,应用能够始终保持更新。
安全 – 通过 HTTPS 来提供服务来防止网络窥探,保证内容不被篡改。
可搜索 – 得益于 W3C manifests 元数据和 service worker 的登记,让搜索引擎能够找到 web 应用。
再次访问 – 通过消息推送等特性让用户再次访问变得容易。

Web App Manifest使Web更像Native

Web App Manifest以JSON的格式定义Web应用的相关配置(应用名称、图标或图像连接、启动URL、自定义特性、启动默认配置、全屏设置等)。

微信小程序原理解析

Service Workers增强Web能力

通过Service Works实现资源离线缓存和更新

微信小程序原理解析

App Shell 提升显示效率

App Shell(应用外壳)是应用的用户界面所需的最基本的 HTML、CSS 和 JavaScript,首次加载后立刻被缓存下来,不需要每次使用时都被下载,而是只异步加载需要的数据,以达到UI保持本地化。

微信小程序原理解析

了解更多pwa资料:
https://developers.google.com/web/progressive-web-apps/

 

Android的MVP框架讲解及MVP与RxJava配合

内容大纲:

  1. Android 开发框架的选择
  2. 如何一步步搭建分层框架
  3. 使用 RxJava 来解决主线程发出网络请求的问题
  4. 结语

一、Android开发框架的选择

由于原生 Android 开发应该已经是一个基础的 MVC 框架,所以在初始开发的时候并没有遇到太多框架上的问题,可是一旦项目规模到了一定的程度,就需要对整个项目的代码结构做一个总体上的规划,最终的目的是使代码可读,维护性好,方便测试。’

只有项目复杂度到了一定程度才需要使用一些更灵活的框架或者结构,简单来说,写个 Hello World 并不需要任何第三方的框架

原生的 MVC 框架遇到大规模的应用,就会变得代码难读,不好维护,无法测试的囧境。因此,Android 开发方面也有很多对应的框架来解决这些问题。

构建框架的最终目的是增强项目代码的可读性维护性方便测试 ,如果背离了这个初衷,为了使用而使用,最终是得不偿失的

从根本上来讲,要解决上述的三个问题,核心思想无非两种:一个是分层 ,一个是模块化 。两个方法最终要实现的就是解耦,分层讲的是纵向层面上的解耦,模块化则是横向上的解耦。下面我们来详细讨论一下 Android 开发如何实现不同层面上的解耦。

解耦的常用方法有两种:分层模块化

横向的模块化对大家来可能并不陌生,在一个项目建立项目文件夹的时候就会遇到这个问题,通常的做法是将相同功能的模块放到同一个目录下,更复杂的,可以通过插件化来实现功能的分离与加载。

纵向的分层,不同的项目可能就有不同的分法,并且随着项目的复杂度变大,层次可能越来越多。

对于经典的 Android MVC 框架来说,如果只是简单的应用,业务逻辑写到 Activity 下面并无太多问题,但一旦业务逐渐变得复杂起来,每个页面之间有不同的数据交互和业务交流时,activity 的代码就会急剧膨胀,代码就会变得可读性,维护性很差。

所以这里我们就要介绍 Android 官方推荐的 MVP 框架,看看 MVP 是如何将 Android 项目层层分解。

二、如何一步步搭建分层框架

如果你是个老司机,可以直接参考下面几篇文章(可在 google 搜到):

  1. Android Application Architecture
  2. Android Architecture Blueprints – Github
  3. Google 官方 MVP 示例之 TODO-MVP – 简书
  4. 官方示例1-todo-mvp – github
  5. dev-todo-mvp-rxjava – github

当然如果你觉得看官方的示例太麻烦,那么本文会通过最简洁的语言来讲解如何通过 MVP 来实现一个合适的业务分层。

对一个经典的 Android MVC 框架项目来讲,它的代码结构大概是下面这样(图片来自参考文献)

pic_001

简单来讲,就是 Activity 或者 Fragment 直接与数据层交互,activity 通过 apiProvider 进行网络访问,或者通过 CacheProvider 读取本地缓存,然后在返回或者回调里对 Activity 的界面进行响应刷新。

这样的结构在初期看来没什么问题,甚至可以很快的开发出来一个展示功能,但是业务一旦变得复杂了怎么办?

我们作一个设想,假如一次数据访问可能需要同时访问 api 和 cache,或者一次数据请求需要请求两次 api。对于 activity 来说,它既与界面的展示,事件等有关系,又与业务数据层有着直接的关系,无疑 activity 层会极剧膨胀,变得极难阅读和维护。

在这种结构下, activity 同时承担了 view 层和 controller 层的工作,所以我们需要给 activity 减负

所以,我们来看看 MVP 是如何做这项工作的(图片来自参考文献)

pic_002

这是一个比较典型的 MVP 结构图,相比于第一张图,多了两个层,一个是 Presenter 和 DataManager 层。

所谓自古图片留不住,总是代码得人心。下面用代码来说明这个结构的实现。

首先是 View 层的 Activity,假设有一个最简单的从 Preference 中获取字符串的界面

public class MainActivity extends Activity implements MainView {

    MainPresenter presenter;
    TextView mShowTxt;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mShowTxt = (TextView)findViewById(R.id.text1);
        loadDatas();
    }

    public void loadDatas() {
        presenter = new MainPresenter();
        presenter.addTaskListener(this);
        presenter.getString();
    }

    @Override
    public void onShowString(String str) {
        mShowTxt.setText(str);
    }
}

Activity 里面包含了几个文件,一个是 View 层的对外接口 MainView,一个是P层 Presenter

首先对外接口 MainView 文件

public interface MainView {
    void onShowString(String json);
}

因为这个界面比较简单,只需要在界面上显示一个字符串,所以只有一个接口 onShowString,再看P层代码

public class MainPresenter {

    MainView mainView;
    TaskManager taskData;

    public MainPresenter() {
        this.taskData = new TaskManager(new TaskDataSourceImpl());
    }

    public MainPresenter test() {
        this.taskData = new TaskManager(new TaskDataSourceTestImpl());
        return this;
    }

    public MainPresenter addTaskListener(MainView viewListener) {
        this.mainView = viewListener;
        return this;
    }

    public void getString() {
        String str = taskData.getTaskName();
        mainView.onShowString(str);
    }

}

可以看到 Presenter 层是连接 Model 层和 View 层的中间层,因此持有 View 层的接口和 Model 层的接口。这里就可以看到 MVP 框架的威力了,通过接口的形式将 View 层和 Model 层完全隔离开来。

接口的作用类似给层与层之间制定的一种通信协议,两个不同的层级相互交流,只要遵守这些协议即可,并不需要知道具体的实现是怎样

看到这里,有人可能就要问,这跟直接调用有什么区别,为什么要大费周章的给 view 层和 Model 层各设置一个接口呢?具体原因,我们看看 Model 层的实现类就知道了。

下面这个文件是 DataManager.java,对应的是图中的 DataManager 模块

/**
 * 从数据层获取的数据,在这里进行拼装和组合
 */
public class TaskManager {
    TaskDataSource dataSource;

    public TaskManager(TaskDataSource dataSource) {
        this.dataSource = dataSource;
    }

    public String getShowContent() {
        //Todo what you want do on the original data
        return dataSource.getStringFromRemote() + dataSource.getStringFromCache();
    }
}

TaskDataSource.java 文件

/**
 * data 层接口定义
 */
public interface TaskDataSource {
    String getStringFromRemote();
    String getStringFromCache();
}

TaskDataSourceImpl.java 文件

public class TaskDataSourceImpl implements TaskDataSource {
    @Override
    public String getStringFromRemote() {
        return "Hello ";
    }

    @Override
    public String getStringFromCache() {
        return "World";
    }
}

TaskDataSourceTestImpl.java 文件

public class TaskDataSourceTestImpl implements TaskDataSource {
    @Override
    public String getStringFromRemote() {
        return "Hello ";
    }

    @Override
    public String getStringFromCache() {
        return " world Test ";
    }
}

从上面几个文件来看, TaskDataSource.java 作为数据层对外的接口, TaskDataSourceImpl.java 是数据层,直接负责数据获取,无论是从api获得,还是从本地数据库读取数据,本质上都是IO操作。 TaskManager 是作为业务层,对获取到的数据进行拼装,然后交给调用层。

这里我们来看看分层的作用

首先来讲业务层 TaskManager,业务层的上层是 View 层,下层是 Data 层。在这个类里,只有一个 Data 层的接口,所以业务层是不关心数据是如何取得,只需要通过接口获得数据之后,对原始的数据进行组合和拼装。因为完全与其上层和下层分离,所以我们在测试的时候,可以完全独立的是去测试业务层的逻辑。

TaskManager 中的 construct 方法的参数是数据层接口,这意味着我们可以给业务层注入不同的数据层实现。 正式线上发布的时候注入 TaskDataSourceImpl 这个实现,在测试业务层逻辑的时候,注入 TaskDataSourceTestImpl.java 实现。

这也正是使用接口来处理每个层级互相通信的好处,可以根据使用场景的不用,使用不同的实现

到现在为止一个基于 MVP 简单框架就搭建完成了,但其实还遗留了一个比较大的问题。

Android 规定,主线程是无法直接进行网络请求,会抛出 NetworkOnMainThreadException 异常

我们回到 Presenter 层,看看这里的调用。因为 presenter 层并不知道业务层以及数据层到底是从网络获取数据,还是从本地获取数据(符合层级间相互透明的原则),因为每次调用都可能存在触发这个问题。并且我们知道,即使是从本地获取数据,一次简单的IO访问也要消耗10MS左右。因此多而复杂的IO可能会直接引发页面的卡顿。

理想的情况下,所有的数据请求都应当在线程中完成,主线程只负责页面渲染的工作

当然,Android 本身提供一些方案,比如下面这种:

public void getString() {
    final Handler mainHandler = new Handler(Looper.getMainLooper());
    new Thread(){
        @Override
        public void run() {
            super.run();
            final String str = taskData.getShowContent();
            mainHandler.post(new Runnable() {
                @Override
                public void run() {
                    mainView.onShowString(str);
                }
            });
        }
    }.start();
}

通过新建子线程进行IO读写获取数据,然后通过主线程的 Looper 将结果通过传回主线程进行渲染和展示。

但每个调用都这样写,首先是新建线程会增加额外的成功,其次就是代码看起来很难读,缩进太多。

好在有了 RxJava ,可以比较方便的解决这个问题。

三、使用RxJava来解决主线程发出网络请求的问题

RxJava 是一个天生用来做异步的工具,相比 AsyncTask, Handler 等,它的优点就是简洁,无比的简洁。

在 Android 中使用 RxJava 需要加入下面两个依赖

compile 'io.reactivex:rxjava:1.0.14' 
compile 'io.reactivex:rxandroid:1.0.1'

这里我们直接介绍如何使用 RxJava 解决这个问题,直接在 presenter 中修改调用方法 getString

public class MainPresenter {

    MainView mainView;
    TaskManager taskData;

    public MainPresenter() {
        this.taskData = new TaskManager(new TaskDataSourceImpl());
    }

    public MainPresenter test() {
        this.taskData = new TaskManager(new TaskDataSourceTestImpl());
        return this;
    }

    public MainPresenter addTaskListener(MainView viewListener) {
        this.mainView = viewListener;
        return this;
    }

    public void getString() {
        Func1 dataAction = new Func1<String,String>() {
                @Override
                public String call(String param) {
                    return  taskData.getTaskName();
                }
            }    
        Action1 viewAction = new Action1<String>() {
                @Override
                public void call( String str) {
                    mainView.onShowString(str);
                }
            };        
        Observable.just("")
            .observeOn(Schedulers.io())
            .map(dataAction)
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(view);

    }

}

简单说明一下,与业务数据层的交互被定义到 Action1 里,然后交由 rxJava,指定 Schedulers.io() 获取到的线程来执行。Shedulers.io() 是专门用来进行IO访问的线程,并且线程会重复利用,不需要额外的线程管理。而数据返回到 View 层的操作是在 Action1 中完全,由 rxJava 交由 AndroidSchedulers.mainThread() 指定的UI主线程来执行。

从代码量上来讲,似比上一种方式要更多了,但实际上,当业务复杂度成倍增加的时候,RxJava 可以采用这种链式编程方式随意的增加调用和返回,而实现方式要比前面的方法灵活得多,简洁得多。

具体的内容就不在这里讲了,大家可以看参考下面的文章(可在 google 搜到):

  1. 给 Android 开发者的 RxJava 详解
  2. RxJava 与 Retrofit 结合的最佳实践
  3. RxJava使用场景小结
  4. How To Use RxJava

RxJava 的使用场景远不止这些,在上面第三篇文章提到了以下几种使用场景:

  1. 取数据先检查缓存的场景
  2. 需要等到多个接口并发取完数据,再更新
  3. 一个接口的请求依赖另一个API请求返回的数据
  4. 界面按钮需要防止连续点击的情况
  5. 响应式的界面
  6. 复杂的数据变换

四、结语

至此为止,通过 MVP+RxJava 的组合,我们已经构建出一个比较灵活的 Android 项目框架,总共分成了四部分:View 层,Presenter 层,Model 业务层,Data 数据持久化层。这个框架的优点大概有以下几点:

  • 每层各自独立,通过接口通信
  • 实现与接口分离,不同场景(正式,测试)可以挂载不同的实现,方便测试和开发写假数据
  • 所有的业务逻辑都在非UI线程中进行,最大限度减少IO操作对UI的影响
  • 使用 RxJava 可以将复杂的调用进行链式组合,解决多重回调嵌套问题

如何高效的使用GITHUB,GITHUB的正确使用方式

转载:http://stormzhang.com/github/2016/07/28/learn-github-from-zero7/

之前发过一系列有关 GitHub 的文章,有同学问了,GitHub 我大概了解了,Git 也差不多会使用了,但是 还是搞不清 GitHub 如何帮助我的工作,怎么提升我的工作效率?

问到点子上了,GitHub 其中一个最重要的作用就是发现全世界最优秀的开源项目,你没事的时候刷刷微博、知乎,人家没事的时候刷刷 GitHub ,看看最近有哪些流行的项目,久而久之,这差距就越来越大,那么如何发现优秀的开源项目呢?这篇文章我就来给大家介绍下。

1. 关注一些活跃的大牛

GitHub 主页有一个类似微博的时间线功能,所有你关注的人的动作,比如 star、fork 了某个项目都会出现在你的时间线上,这种方式适合我这种比较懒的人,不用主动去找项目,而这种基本是我每天获取信息的一个很重要的方式。不知道怎么关注这些人?那么很简单,关注我 stormzhang ,以及我 GitHub 上关注的一些大牛,基本就差不多了。

图片描述

点击下图的 Explore 菜单到“发现”页面

图片描述

紧接着点击 Trending 按钮

图片描述

这个 Trending 页面是干嘛的呢?直译过来就是趋势的意思,就是说这个页面你可以看到最近一些热门的开源项目,这个页面可以算是很多人主动获取一些开源项目最好的途径,可以选择「当天热门」、「一周之内热门」和「一月之内热门」来查看,并且还可以分语言类来查看,比如你想查看最近热门的 Android 项目,那么右边就可以选择 Java 语言。

图片描述

这样页面推荐大家每隔几天就去看下,主动发掘一些优秀的开源项目。

除了 Trending ,还有一种最主动的获取开源项目的方式,那就是 GitHub 的 Search 功能。

举个例子,你是做 Android 的,接触 GitHub 没多久,那么第一件事就应该输入 android 关键字进行搜索,然后右上角选择按照 star 来排序,结果如下图:

图片描述

如果你是学习 iOS 的,那么不妨同样的方法输入 iOS 关键字看看结果:

图片描述

可以看到按照 star 数,排名靠前基本是一些比较火的项目,一定是很有用,才会这么火。值得一提的是左侧依然可以选择语言进行过滤。

而对于实际项目中用到一些库,基本上都会第一时间去 GitHub 搜索下有没有类似的库,比如项目中想采用一个网络库,那么不妨输入 android http 关键字进行搜索,因为我只想找到关于 Android 的项目,所以搜索的时候都会加上 android 关键字,按照 star 数进行排序,我们来看下结果:

图片描述

可以看到 Retrofit、OkHttp、android-async-http 是最流行的网络库,只不过 android-async-http 的作者不维护了,之前很多人问我网络库用哪个比较好?哪怕你对每个网络库都不是很了解,那么单纯的按照这种方式你都该优先选择 Retrofit 或者 OkHttp,而目前绝大部分 Android 开发者确实也都是在用这两个网络库,当然还有部分在用 Volley 的,因为 google 没有选择在 GitHub 开源 volley,所以搜不到 volley 的上榜。

除此之外,GitHub 的 Search 还有一些小技巧,比如你想搜索的结果中 star 数大于1000的,那么可以这样搜索:

android http stars:>1000

当然还有其他小技巧,但是我觉得不是很重要,就不多说了。

有些人如果习惯用 Google 进行搜索,那么想搜索 GitHub 上的结果,不妨前面加 GitHub 关键字就ok了,比如我在 google 里输入 GitHub android http ,每个关键字用空格隔开,然后搜索结果如下:

图片描述

可以看到,基本也是我们想要的结果,只不过排序就不是单纯的按照 star 来排序了。

福利大放送

相信以上三种方法够大家遨游在 GitHub 的海洋了,最后给大家献上一些福利,这些项目是 GitHub 上影响力很大,同时又对你们很有用的项目:

这个项目目前 star 数排名 GitHub 第三,总 star 数超过6w,这个项目整理了所有跟编程相关的免费书籍,而且全球多国语言版的都有,中文版的在这里:free-programming-books-zh,有了这个项目,理论上你可以获取任何编程相关的学习资料,强烈推荐给你们!

俗话说,不会用 shell 的程序员不是真正的程序员,所以建议每个程序员都懂点 shell,有用不说,装逼利器啊!而 oh-my-zsh 毫无疑问就是目前最流行,最酷炫的 shell,不多说了,懂得自然懂,不懂的以后你们会懂的!

GitHub 上有各种 awesome 系列,简单来说就是这个系列搜罗整理了 GitHub 上各领域的资源大汇总,比如有 awesome-android, awesome-ios, awesome-java, awesome-python 等等等,就不截图了,你们自行去感受。

GitHub 的使用有各种技巧,只不过基本的就够我们用了,但是如果你对 GitHub 超级感兴趣,想更多的了解 GitHub 的使用技巧,那么这个项目就刚好是你需要的,每个 GitHub 粉都应该知道这个项目。

这个项目是我一个好朋友 Trinea 整理的一个开源项目,基本囊括了所有 GitHub 上的 Android 优秀开源项目,但是缺点就是太多了不适合快速搜索定位,但是身为 Android 开发无论如何你们应该知道这个项目。

这个项目跟上面的区别是,这个项目只整理了所有跟 Android UI 相关的优秀开源项目,基本你在实际开发终于到的各种效果上面都几乎能找到类似的项目,简直是开发必备。

这个项目是我的邪教群的一位管理员整理的,几乎包括了国内各种学习 Android 的资料,简直太全了,我为这个项目也稍微做了点力,强烈推荐你们收藏起来。

这个就不多说了,之前给大家推荐过的,国内一线互联网公司内部面试题库。

这是一份非常详细的面试资料,涉及 Android、Java、设计模式、算法等等等,你能想到的,你不能想到的基本都包含了,可以说是适应于任何准备面试的 Android 开发者,看完这个之后别说你还不知道怎么面试!

总结

GitHub 上优秀开源项目真的是一大堆,就不一一推荐了,授人以鱼不如授人以渔,请大家自行主动发掘自己需要的开源项目吧,不管是应用在实际项目上,还是对源码的学习,都是提升自己工作效率与技能的很重要的一个渠道,总有一天,你会突然意识到,原来不知不觉你已经走了这么远!

觉得不错,不妨随手转发、点赞,都是对我良心张莫大的鼓励!

最全面的Android热修复技术——Tinker、nuwa、AndFix、Dexposed

这篇文章分为这么几个部分:

  • 一、是什么
  • 二、局限性
  • 三、原理
  • 四、实际案例
  • 五、选择
  • 六、总结

一、热修复技术是什么,怎么出现的呢,为什么需要?

当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布。不仅大大增加开发成本也会影响到产品的口碑,造成用户流失。

这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?

于是涌现出来很多热补丁方案。

能够让应用能够在无需重新安装的情况实现更新,帮助应用快速建立动态修复能力。

二、局限性与适用场景

  • 补丁只能针对单一客户端版本,随着版本差异变大补丁体积也会增大;
  • 补丁不能支持所有的修改,例如AndroidManifest;
  • 补丁无论对代码还是资源的更新成功率都无法达到100%。

既然补丁技术无法完全代替升级,那它适合使用在哪些场景呢?

1. 轻量而快速的升级

热补丁技术也可以理解为一个动态修改代码与资源的通道,它适合于修改量较少的情况。一般在300k以内,以Android用户的升级习惯,即使是相对活跃的微信也需要10天以上的时间去覆盖50%的用户。使用补丁技术,我们能做到1天覆盖70%以上。这也是基于补丁体积较小,可以直接使用移动网络下载更新。

2.远端调试

一入Android深似海,Android开发的另外一个痛是机型的碎片化。我们也许都会遇到"本地不复现","日志查不出","联系用户不鸟你"的烦恼。所以补丁机制非常适合使用在远端调试上。即我们需要具备只特定用户发送补丁的能力,这对我们查找问题非常有帮助。

3.数据统计

数据统计在微信中也占据着非常重要的位置,我们也非常希望将热补丁与数据统计结合的更好。事实上,热补丁无论在普通的数据统计还是ABTest都有着非常大的优势。例如若我想对同一批用户做两种test, 传统方式无法让这批用户去安装两个版本。使用补丁技术,我们可以方便的对同一批用户不停的更换补丁。

4.其他

事实上,Android官方也使用热补丁技术实现Instant Run。它分为Hot Swap、Warm Swap与Cold Swap三种方式,大家可以参考英文介绍,也可以看参考文章中的翻译稿。最新的Instant App应该也是采用类似的原理,但是Google Play是不允许下发代码的,这个海外App需要注意一下。

三、热修复的原理

1、通过更改dex加载顺序实现热修复

其核心原理就是通过更改含有bug的dex文件的加载顺序。在dex的加载中,若以找到方法则不会继续查找,所以如果能让修复之后的方法在含有bug的方法之前加载就能达到修复bug的目的。

lassLoader

原腾讯空间Android工程师,陈钟老师发明的热补丁方案,是他在看源码的时候偶然发现的切入点。

我们知道,multidex方案的实现,其实就是把多个dex放进app的classloader之中,从而使得所有dex的类都能被找到。而实际上findClass的过程中,如果出现了重复的类,参照下面的类加载的实现,是会使用第一个找到的类的。

public Class findClass(String name, List<Throwable> suppressed) {  

    for (Element element : dexElements) {  //每个Element就是一个dex文件
        DexFile dex = element.dexFile;
        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
            if (clazz != null) {
              return clazz;
            }
        }
    }
    if (dexElementsSuppressedExceptions != null) {  
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

该热补丁方案就是从这一点出发,只要把有问题的类修复后,放到一个单独的dex,通过反射插入到dexElements数组的最前面,不就可以让虚拟机加载到打完补丁的class了吗。

说到此处,似乎已经是一个完整的方案了,但在实践中,会发现运行加载类的时候报preverified错误,原来在DexPrepare.cpp,将dex转化成odex的过程中,会在DexVerify.cpp进行校验,验证如果直接引用到的类和clazz是否在同一个dex,如果是,则会打上CLASS_ISPREVERIFIED标志。通过在所有类(Application除外,当时还没加载自定义类的代码)的构造函数插入一个对在单独的dex的类的引用,就可以解决这个问题。空间使用了javaassist进行编译时字节码插入。

开源实现有Nuwa, HotFix, DroidFix。

2、通过Native替换方法指针的方式实现热修复

这里主要是阿里开源的两个热修复框架:Dexpost AndFix都是通过Native层使用指针替换的方法替换bug,达到修复bug的目的的,具体可参考其github文章。

Dexposed

基于Xposed的AOP框架,方法级粒度,可以进行AOP编程、插桩、热补丁、SDK hook等功能。

Xposed需要Root权限,是因为它要修改其他应用、系统的行为,而对单个应用来说,其实不需要root。 Xposed通过修改Android Dalvik运行时的Zygote进程,并使用Xposed Bridge来hook方法并注入自己的代码,实现非侵入式的runtime修改。比如蜻蜓fm和喜马拉雅做的事情,其实就很适合这种场景,别人反编译市场下载的代码是看不到patch的行为的。小米(onVmCreated里面还未小米做了资源的处理)也重用了dexposed,去做了很多自定义主题的功能,还有沉浸式状态栏等。

我们知道,应用启动的时候,都会fork zygote进程,装载class和invoke各种初始化方法,Xposed就是在这个过程中,替换了app_process,hook了各种入口级方法(比如handleBindApplication、ServerThread、ActivityThread、ApplicationPackageManager的getResourcesForApplication等),加载XposedBridge.jar提供动态hook基础。

其具体native实现则在Xposed的libxposed_common.cpp里面有注册,根据系统版本分发到libxposed_dalvik和libxposed_art里面,以dalvik为例大致来说就是记录下原来的方法信息,并把方法指针指向我们的hookedMethodCallback,从而实现拦截的目的。

方法级的替换是指,可以在方法前、方法后插入代码,或者直接替换方法。只能针对java方法做拦截,不支持C的方法。

来说说硬伤吧,不支持art,不支持art,不支持art。
重要的事情要说三遍。尽管在6月,项目网站的roadmap就写了7、8月会支持art,但事实是现在还无法解决art的兼容。

另外,如果线上release版本进行了混淆,那写patch也是一件很痛苦的事情,反射+内部类,可能还有包名和内部类的名字冲突,总而言之就是写得很痛苦。

Dexpost:(未测试)
1)原理:在底层虚拟机运行时hoop方法;
2)地址:https://github.com/alibaba/dexposed
3)缺点:适配方面存在一些问题,目前不支持android6.0,5,1;art运行时;
4)优点:无需重启就可以达到修复bug的目的;

AndFix

同样是方法的hook,AndFix不像Dexposed从Method入手,而是以Field为切入点。

先看Java入口,AndFixManager.fix:

/**
 * fix
 *
 * @param file        patch file
 * @param classLoader classloader of class that will be fixed
 * @param classes     classes will be fixed
 */
public synchronized void fix(File file, ClassLoader classLoader, List<String> classes) {
        // 省略...判断是否支持,安全检查,读取补丁的dex文件

        ClassLoader patchClassLoader = new ClassLoader(classLoader) {
            @Override
            protected Class<?> findClass(String className) throws ClassNotFoundException {
                Class<?> clazz = dexFile.loadClass(className, this);
                if (clazz == null && className.startsWith("com.alipay.euler.andfix")) {
                    return Class.forName(className);// annotation’s class not found
                }
                if (clazz == null) {
                    throw new ClassNotFoundException(className);
                }
                return clazz;
            }
        };
        Enumeration<String> entrys = dexFile.entries();
        Class<?> clazz = null;
        while (entrys.hasMoreElements()) {
            String entry = entrys.nextElement();
            if (classes != null && !classes.contains(entry)) {
                continue;// skip, not need fix
            }
      // 找到了,加载补丁class
            clazz = dexFile.loadClass(entry, patchClassLoader);
            if (clazz != null) {
                fixClass(clazz, classLoader);
            }
        }
    } catch (IOException e) {
        Log.e(TAG, "pacth", e);
    }
}

看来最终fix是在fixClass方法:



private void fixClass(Class<?> clazz, ClassLoader classLoader) {
  Method[] methods = clazz.getDeclaredMethods();
  MethodReplace methodReplace;
  String clz;
  String meth;
  // 遍历补丁class里的方法,进行一一替换,annotation则是补丁包工具自动加上的
  for (Method method : methods) {
    methodReplace = method.getAnnotation(MethodReplace.class);
    if (methodReplace == null)
      continue;
    clz = methodReplace.clazz();
    meth = methodReplace.method();
    if (!isEmpty(clz) && !isEmpty(meth)) {
      replaceMethod(classLoader, clz, meth, method);
    }
  }
}

private void replaceMethod(ClassLoader classLoader, String clz, String meth, Method method) {
  try {
    String key = clz + "@" + classLoader.toString();
    Class<?> clazz = mFixedClass.get(key);
    if (clazz == null) {// class not load
      // 要被替换的class
      Class<?> clzz = classLoader.loadClass(clz);
      // 这里也很黑科技,通过C层,改写accessFlags,把需要替换的类的所有方法(Field)改成了public,具体可以看Method结构体
      clazz = AndFix.initTargetClass(clzz);
    }
    if (clazz != null) {// initialize class OK
      mFixedClass.put(key, clazz);
      // 需要被替换的函数
      Method src = clazz.getDeclaredMethod(meth, method.getParameterTypes());
      // 这里是调用了jni,art和dalvik分别执行不同的替换逻辑,在cpp进行实现
      AndFix.addReplaceMethod(src, method);
    }
  } catch (Exception e) {
    Log.e(TAG, "replaceMethod", e);
  }
}

在dalvik和art上,系统的调用不同,但是原理类似,这里我们尝个鲜,以6.0为例art_method_replace_6_0:



// 进行方法的替换
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
    art::mirror::ArtMethod* smeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(src);
    art::mirror::ArtMethod* dmeth = (art::mirror::ArtMethod*) env->FromReflectedMethod(dest);

    dmeth->declaring_class_->class_loader_ =
            smeth->declaring_class_->class_loader_; //for plugin classloader
    dmeth->declaring_class_->clinit_thread_id_ =
            smeth->declaring_class_->clinit_thread_id_;
    dmeth->declaring_class_->status_ = smeth->declaring_class_->status_-1;

  // 把原方法的各种属性都改成补丁方法的
    smeth->declaring_class_ = dmeth->declaring_class_;
    smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
    smeth->access_flags_ = dmeth->access_flags_;
    smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
    smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
    smeth->method_index_ = dmeth->method_index_;
    smeth->dex_method_index_ = dmeth->dex_method_index_;

  // 实现的指针也替换为新的
    smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
            dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
    smeth->ptr_sized_fields_.entry_point_from_jni_ =
            dmeth->ptr_sized_fields_.entry_point_from_jni_;
    smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

    LOGD("replace_6_0: %d , %d",
            smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
            dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}

// 这就是上面提到的,把方法都改成public的,所以说了解一下jni还是很有必要的,java世界在c世界是有映射关系的
void setFieldFlag_6_0(JNIEnv* env, jobject field) {
    art::mirror::ArtField* artField =
            (art::mirror::ArtField*) env->FromReflectedField(field);
    artField->access_flags_ = artField->access_flags_ & (~0x0002) | 0x0001;
    LOGD("setFieldFlag_6_0: %d ", artField->access_flags_);
}

在dalvik上的实现略有不同,是通过jni bridge来指向补丁的方法。

使用上,直接写一个新的类,会由补丁工具会生成注解,描述其与要打补丁的类和方法的对应关系。

四、实际案例

QQ空间:

空间Android独立版5.2发布后,收到用户反馈,结合版无法跳转到独立版的访客界面,每天都较大的反馈。在以前只能紧急换包,重新发布。成本非常高,也影响用户的口碑。最终决定使用热补丁动态修复技术,向用户下发Patch,在用户无感知的情况下,修复了外网问题,取得非常好的效果。

**解决方案** 该方案基于的是android dex分包方案的,关于dex分包方案,网上有几篇解释了,所以这里就不再赘述,具体可以看这里

简单的概括一下,就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当用到这个重复的类的时候,系统会选择哪个类进行加载呢?

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。
理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类,如下图:

20160725234620720
在此基础上,我们构想了热补丁的方案,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面,如下图:

20160725234649251

好,该方案基于第二个拆分dex的方案,方案实现如果懂拆分dex的原理的话,大家应该很快就会实现该方案,如果没有拆分dex的项目的话,可以参考一下谷歌的multidex方案实现。然后在插入数组的时候,把补丁包插入到最前面去。

好,看似问题很简单,轻松的搞定了,让我们来试验一下,修改某个类,然后打包成dex,插入到classloader,当加载类的时候出现了一个错误,需要我们打上一个标志 : 如果引用者(也就是ModuleManager)这个类被打上了CLASS_ISPREVERIFIED标志,那么就会进行dex的校验。

这里写图片描述
这段代码是dex转化成odex(dexopt)的代码中的一段,我们知道当一个apk在安装的时候,apk中的classes.dex会被虚拟机(dexopt)优化成odex文件,然后才会拿去执行。

虚拟机在启动的时候,会有许多的启动参数,其中一项就是verify选项,当verify选项被打开的时候,上面doVerify变量为true,那么就会执行dvmVerifyClass进行类的校验,如果dvmVerifyClass校验类成功,那么这个类会被打上CLASS_ISPREVERIFIED的标志

概括一下就是如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED:

20160725235144478

所以为了实现补丁方案,所以必须从这些方法中入手,防止类被打上CLASS_ISPREVERIFIED标志。
最终空间的方案是往所有类的构造函数里面插入了一段代码,代码如下**

if (ClassVerifier.PREVENT_VERIFY) {
    System.out.println(AntilazyLoad.class);
}

20160725235328644

其中AntilazyLoad类会被打包成单独的hack.dex,这样当安装apk的时候,classes.dex内的类都会引用一个在不相同dex中的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED的标志了,只要没被打上这个标志的类都可以进行打补丁操作。

然后在应用启动的时候加载进来.AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么他也是不存在的,这样屏幕就会出现茫茫多的类AntilazyLoad找不到的log。 所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志)

20160725235505545

之所以选择构造函数是因为他不增加方法数,一个类即使没有显式的构造函数,也会有一个隐式的默认构造函数。

空间使用的是在字节码插入代码,而不是源代码插入,使用的是javaassist库来进行字节码插入的。

隐患:
虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,我们强制防止类被打上标志是否会影响性能?这里我们会做一下更加详细的性能测试.但是在大项目中拆分dex的问题已经比较严重,很多类都没有被打上这个标志。
如何打包补丁包:
1. 空间在正式版本发布的时候,会生成一份缓存文件,里面记录了所有class文件的md5,还有一份mapping混淆文件。
2. 在后续的版本中使用-applymapping选项,应用正式版本的mapping文件,然后计算编译完成后的class文件的md5和正式版本进行比较,把不相同的class文件打包成补丁包。
备注:该方案现在也应用到我们的编译过程当中,编译不需要重新打包dex,只需要把修改过的类的class文件打包成patch dex,然后放到sdcard下,那么就会让改变的代码生效。

微信热补丁方案:

有没有那么一种方案,能做到开发透明,但是却没有QZone方案的缺陷呢?Instant Run的冷插拔与buck的exopackage或许能给我们灵感,它们的思想都是全量替换新的Dex。即我们完全使用了新的Dex,那样既不出现Art地址错乱的问题,在Dalvik也无须插桩。当然考虑到补丁包的体积,我们不能直接将新的Dex放在里面。但我们可以将新旧两个Dex的差异放到补丁包中,最简单我们可以采用BsDiff算法

20160726000830168

简单来说,在编译时通过新旧两个Dex生成差异path.dex。在运行时,将差异patch.dex重新跟原始安装包的旧Dex还原为新的Dex。这个过程可能比较耗费时间与内存,所以我们是单独放在一个后台进程:patch中。为了补丁包尽量的小,微信自研了DexDiff算法,它深度利用Dex的格式来减少差异的大小。它的粒度是Dex格式的每一项,可以充分利用原本Dex的信息,而BsDiff的粒度是文件,AndFix/QZone的粒度为class。

20160726000934706

这块后面我希望后面用单独的文章来讲述,这里先做一个铺垫,大致的效果如下图。在最极端的情况,由于利用了原本dex的信息完全替换一个13M的Dex,我们的补丁大小也仅仅只有6.6M。

但是这套方案并非没有缺点,它带来的问题有两个:

占用Rom体积;这边大约是你修改Dex数量的1.5倍(dexopt与dex压缩成jar)的大小。
一个额外的合成过程;虽然我们单独放在一个进程上处理,但是合成时间的长短与内存消耗也会影响最终的成功率。

微信的热补丁方案叫做Tinker,也算缅怀一下Dota中的地精修补匠,希望能做到无限刷新。

20160726001103473

限于篇幅,这里对Dex、library以及资源的更多技术细节并没有详细的论述,这里希望放在后面的单独文章中。我们最后从整体比较一下这几种方案:

20160726001221420

若不care性能损耗与补丁包大小,QZone方案是最简单且成功率最高的方案(没有单独的合成过程)。相对Tinker来说,它的占用Rom体积也更小。另一方面,QZone与Tinker的成功率大约相差3%左右。

事实上,一个完整的框架应该也是一个容易使用的框架。Tinker对补丁版本管理、进程管理、安全校验等都有着很好的支持。同时我们也支持gradle与命名行两种接入方式。希望在不久的将来,它可以很快的跟大家见面。

五、我的选择

最终我们App的热修复方案选择的是AndFix,原因有三:

(1)AndFix支持android2.3-6.0,所以在机型上的是适配上是没问题的;

(2)AndFix是由阿里开源的,并且持续维护中,目前不少公司已经使用其作为自身App的热修复方案;

(3)通过修改Dex加载顺序的方式实现热修复需要重新启动App,并且相应的开源框架多多少少存在着问题,没有持续的维护;不需要重启App

因此我们最终选择了AndFix作为我们的开源方案。具体的AndFix集成方式可参考github中AndFix的介绍

(1)在App的Application的onCreate方法中执行AndFix的初始化操作;

(2)判断服务器端是否有可更新的热修复差异包

(3)若无则直接退出,若有则下载并执行修复动作

(4)修复完成之后删除下载的补丁差异包

(5)在判断服务器端是否有可更新的补丁包的时候可添加灰度,如版本,渠道,用户等,实现对补丁包定制化的修复

另外需要说明的是:若一个版本中存在着多个bug,则一般的都是让后一个补丁包覆盖前一个补丁包,并删除前一个补丁包,简单来说就是对于每一个版本至多有一个补丁包

最后贴上App端AndFix的实现源码:

public class AndfixManager {
    public static final String TAG = AndfixManager.class.getSimpleName();

    // AndfixManager单例对象
    private static AndfixManager instance = null;
    // 补丁文件名称
    public static final String PATCH_FILENAME = "/patchname.apatch";

    public static PatchManager patchManager = null;
    private AndfixManager() {}

    /**     * 线程安全之懒汉模式实现单例模型     * @return     */
    public static synchronized AndfixManager getInstance() {
        return instance == null ? new AndfixManager() : instance;
    }

    /**     * 执行andfix初始化操作     */
    public static void init(Context mContext) {
        if (mContext == null) {
            L.i("初始化热修复框架,参数错误!!!");
            return;
        }
        patchManager = new PatchManager(mContext);
        // 初始化patch版本,这里初始化的是当前的App版本;
        patchManager.init(VersionUtils.getVersionName(mContext));
        // 加载已经添加到PatchManager中的patch
        patchManager.loadPatch();


        downLoadAndAndPath(mContext);

    }

    /**     * 请求服务器获取补丁文件并加载     */
    public static void downLoadAndAndPath(final Context mContext) {
        // 请求服务器获取差异包
        ExtInterface.GetShContent.Request.Builder request = ExtInterface.GetShContent.Request.newBuilder();

        // 获取本地保存的补丁包版本号
        final String patchVersion = AndfixSp.getPatchVersion(mContext);
        L.i(TAG, "patchVersion:" + patchVersion);
        if (!TextUtils.isEmpty(patchVersion)) {
            request.setShVersion(patchVersion);
        } else {
            request.setShVersion("0");
        }
        NetworkTask task = new NetworkTask(Cmd.CmdCode.GetShContent_SSL_VALUE);
        task.setBusiData(request.build().toByteArray());
        NetworkUtils.executeNetwork(task, new HttpResponse.NetWorkResponse<UUResponseData>() {
            @Override
            public void onSuccessResponse(UUResponseData responseData) {
                if (responseData.getRet() == 0) {
                    try {
                        ExtInterface.GetShContent.Response response = ExtInterface.GetShContent.Response.parseFrom(responseData.getBusiData());
                        // 若返回成功,则更新脚本下载补丁包
                        if (response.getRet() == 0) {
                            ByteString zipDatas = response.getContent();
                            // 数据解压缩
                            byte[] oriDatas = GZipUtils.decompress(zipDatas.toByteArray());
                            String patchFileName = mContext.getCacheDir() + PATCH_FILENAME;
                            L.i(TAG, "patchFileName:" + response.getShVersion());
                            // 将byte数组数据写入文件
                            boolean boolResult = getFileFromBytes(patchFileName, oriDatas);
                            // 写入文件成功则加载
                            if (boolResult) {
                                patchManager.removeAllPatch();
                                patchManager.addPatch(patchFileName);

                                // 保存补丁版本号
                                AndfixSp.putPatchVersion(mContext, response.getShVersion());
                                // 删除补丁文件
                                File files = new File(patchFileName);
                                if (files.exists()) {
                                    files.delete();
                                }
                            }

                        } else {
                            // -1 请求失败
                            // 1 请求成功,但是没有更新版本的脚本
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }

            @Override
            public void onError(VolleyError errorResponse) {
            }

            @Override
            public void networkFinish() {
            }
        });

    }


    /**     * 根据数组获取文件     * @param path     * @param oriDatas     */
    public static boolean getFileFromBytes(String path, byte[] oriDatas) {
        boolean result = false;
        if (TextUtils.isEmpty(path)) {
            return result;
        }
        if (oriDatas == null || oriDatas.length == 0) {
            return result;
        }

        try {
            FileOutputStream fos = new FileOutputStream(path);
            fos.write(oriDatas);
            fos.close();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }

    }
}            

总结:

android的热修复原理大体上分为两种,其一是通过dex的执行顺序实现Apk热修复的功能,但是其需要将App重启才能生效,其二是通过Native修改函数指针的方式实现热修复。

图解AndFix原理:

20160726005949549

使用apkPatch 工具进行差分生成补丁

具体语法:

apkpatch.bat -f version2-fixed.apk -t version1.apk -o patch -k itheima.jks -p
testtest -a it -e testtest
具体含义
-f 新版本apk 即修复后的apk
-t 旧版本apk 即修复前的apk
-o 差分包即patch 补丁文件存放的文件夹当前为patch 文件夹
-k 证书eclipse 下为.keystore 而在androidstudio 下是.jks 实验证明可以混用
-p 使用密码当前是testest
-a 别名当前是it
-e 别名对应的密码当前是testest

Android热修复技术选型——HotFix、AndFix、Tinker、nuwa

2015年以来,Android开发领域里对热修复技术的讨论和分享越来越多,同时也出现了一些不同的解决方案,如QQ空间补丁方案、阿里AndFix以及微信Tinker,它们在原理各有不同,适用场景各异,到底采用哪种方案,是开发者比较头疼的问题。本文希望通过介绍QQ空间补丁、Tinker以及基于AndFix的阿里百川HotFix技术的原理分析和横向比较,帮助开发者更深入了解热修复方案。

技术背景

一、正常开发流程

21

从流程来看,传统的开发流程存在很多弊端:

  • 重新发布版本代价太大

  • 用户下载安装成本太高
  • BUG修复不及时,用户体验太差

二、热修复开发流程

72

而热修复的开发流程显得更加灵活,优势很多:

  • 无需重新发版,实时高效热修复
  • 用户无感知修复,无需下载新的应用,代价小
  • 修复成功率高,把损失降到最低

业界热门的热修复技术

热修复作为当下热门的技术,在业界内比较著名的有阿里巴巴的AndFix、Dexposed,腾讯QQ空间的超级补丁技术和微信的Tinker。最近阿里百川推出的HotFix热修复服务就基于AndFix技术,定位于线上紧急BUG的即时修复,所以AndFix技术这块我们重点分析阿里百川HotFix。下面,我们就分别介绍QQ空间超级热补丁技术和微信的Tinker以及阿里百川HotFix技术。

一、QQ空间超级补丁技术

超级补丁技术基于DEX分包方案,使用了多DEX加载的原理,大致的过程就是:把BUG方法修复以后,放到一个单独的DEX里,插入到dexElements数组的最前面,让虚拟机去加载修复完后的方法。

53

当patch.dex中包含Test.class时就会优先加载,在后续的DEX中遇到Test.class的话就会直接返回而不去加载,这样就达到了修复的目的。

但是有一个问题是,当两个调用关系的类不在同一个DEX时,就会产生异常报错。我们知道,在APK安装时,虚拟机需要将classes.dex优化成odex文件,然后才会执行。在这个过程中,会进行类的verify操作,如果调用关系的类都在同一个DEX中的话就会被打上CLASS_ISPREVERIFIED的标志,然后才会写入odex文件。

所以,为了可以正常的进行打补丁修复,必须避免类被打上CLASS_ISPREVERIFIED标志,具体的做法就是单独放一个类在另外DEX中,让其他类调用。

我们来逆向手机QQ空间APK看一下具体的实现:

先进入程序入口QZoneRealApplication,在attachBaseContext中进行了两步操作:修复CLASS_ISPREVERIFIED标志导致的unexpected DEX problem异常、加载修复的DEX。

24

1. 修复unexpectedDEX problem异常

先看代码:

25

可以看到,这里是要加载一个libs目录下的dalvikhack.jar。在项目的assets/libs找到该文件,解压得到classes.dex文件,逆向打开该DEX文件,

124

通过不同的DEX加载进来,然后在每一个类的构造方法中引用其他DEX中的唯一类AnitLazyLoad,避免类被打上CLASS_ISPREVERIFIED标志。

17

在无修复的情况下,将DO_VERIFY_CLASSES设置为false,提高性能。只有在需要修复的时候,才设置为true。

8

至于如何加载进来,与接下来第二个步骤基本相同。

2. 加载修复的DEX

从loadPatchDex()方法进入,经过几次跳转,到达核心的代码段,SystemClassLoaderInjector.c()。由于进行了混淆和多次方法的跳转,于是将核心代码段做了如下整理:

29

修复的步骤为:

1. 可以看出是通过获取到当前应用的Classloader,即为BaseDexClassloader

2. 通过反射获取到他的DexPathList属性对象pathList

3. 通过反射调用pathList的dexElements方法把patch.dex转化为Element[]

4. 两个Element[]进行合并,把patch.dex放到最前面去

5. 加载Element[],达到修复目的

整体的流程图如下:

110

从流程图来看,可以很明显的找到这种方式的特点:

优势:

  1. 没有合成整包(和微信Tinker比起来),产物比较小,比较灵活
  2. 可以实现类替换,兼容性高。(某些三星手机不起作用)

不足:

1. 不支持即时生效,必须通过重启才能生效。

2. 为了实现修复这个过程,必须在应用中加入两个DEX!dalvikhack.dex中只有一个类,对性能影响不大,但是对于patch.dex来说,修复的类到了一定数量,就需要花不少的时间加载。对手淘这种航母级应用来说,启动耗时增加2s以上是不能够接受的事。

3. 在ART模式下,如果类修改了结构,就会出现内存错乱的问题。为了解决这个问题,就必须把所有相关的调用类、父类子类等等全部加载到patch.dex中,导致补丁包异常的大,进一步增加应用启动加载的时候,耗时更加严重。

二、微信Tinker

微信针对QQ空间超级补丁技术的不足提出了一个提供DEX差量包,整体替换DEX的方案。主要的原理是与QQ空间超级补丁技术基本相同,区别在于不再将patch.dex增加到elements数组中,而是差量的方式给出patch.dex,然后将patch.dex与应用的classes.dex合并,然后整体替换掉旧的DEX,达到修复的目的。

211

我们来逆向微信APK看一下具体的实现:

先找到应用入口TinkerApplication,在onBaseContextAttached()调用了loadTinker(),

112

进入TinkerLoader的tryLoad()方法中,

1122

从方法名可以预见,在tryLoadPatchFilesInternal()中尝试加载本地的补丁,再经过跳转进入核心修复功能类SystemClassLoaderAdder.class中。

123

代码中可以看出,根据Android版本的不同,分别采取具体的修复操作,不过原理都是一样的。我们以V19为例:

213

从代码中可以看到,通过反射操作得到PathClassLoader的DexPatchList,反射调用patchlist的makeDexElements()方法吧本地的DEX文件直接替换到Element[]数组中去,达到修复的目的。

对于如何进行patch.dex与classes.dex的合并操作,这里微信开启了一个新的进程,开启新进程的服务TinkerPatchService进行合并。

114

整体的流程如下:

115

从流程图来看,同样可以很明显的找到这种方式的特点:

优势:

  1. 合成整包,不用在构造函数插入代码,防止verify,verify和opt在编译期间就已经完成,不会在运行期间进行
  2. 性能提高。兼容性和稳定性比较高。
  3. 开发者透明,不需要对包进行额外处理。

不足:

  1. 与超级补丁技术一样,不支持即时生效,必须通过重启应用的方式才能生效。
  2. 需要给应用开启新的进程才能进行合并,并且很容易因为内存消耗等原因合并失败。
  3. 合并时占用额外磁盘空间,对于多DEX的应用来说,如果修改了多个DEX文件,就需要下发多个patch.dex与对应的classes.dex进行合并操作时这种情况会更严重,因此合并过程的失败率也会更高。

三、阿里百川HotFix

阿里百川推出的热修复HotFix服务,相对于QQ空间超级补丁技术和微信Tinker来说,定位于紧急bug修复的场景下,能够最及时的修复bug,下拉补丁立即生效无需等待。

16

1、AndFix实现原理

AndFix不同于QQ空间超级补丁技术和微信Tinker通过增加或替换整个DEX的方案,提供了一种运行时在Native修改Filed指针的方式,实现方法的替换,达到即时生效无需重启,对应用无性能消耗的目的。

原理图如下:

117

2、AndFix实现过程

对于实现方法的替换,需要在Native层操作,经过三个步骤:

18

接下来以Dalvik设备为例,来分析具体的实现过程:

2.1 setup()

119

对于Dalvik来说,遵循JIT即时编译机制,需要在运行时装载libdvm.so动态库,获取以下内部函数:

1) dvmThreadSelf( ):查询当前的线程;

2)dvmDecodeIndirectRef():根据当前线程获得ClassObject对象。

2.2 setFieldFlag

020

该操作的目的:让private、protected的方法和字段可被动态库看见并识别。原因在于动态库会忽略非public属性的字段和方法。

2.3 replaceMethod

021

该步骤是方法替换的核心,替换的流程如下:

022

AndFix对ART设备同样支持,具体的过程与Dalvik相似,这里不再赘述。

从技术原理,不难看出阿里百川HotFix的几个特点:

最大的优势在于

  1. BUG修复的即时性
  2. 补丁包同样采用差量技术,生成的PATCH体积小
  3. 对应用无侵入,几乎无性能损耗

不足:

  1. 不支持新增字段,以及修改<init>方法,也不支持对资源的替换。
  2. 由于厂商的自定义ROM,对少数机型暂不支持。

综合分析如下:

023

热修复的坑和解

我们可以看到,QQ空间超级补丁技术和微信Tinker的修复原理都基于类加载,在功能上已经支持类、资源的替换和新增,功能非常强大。既然已经有了这么强大的热修复技术,为什么阿里百川还要推出自己的热修复方案HotFix呢?

一、多DEX带来的性能问题和影响

我们知道,多DEX方案用来解决应用方法数65k的问题,现在Google也官方支持了MultiDex的实现方案。但是,这实在是应用因方法数超出而作出的不得已的下策,但是超级补丁技术和Tinker作为一种热修复的方案,平生给应用增加了多个DEX,而多DEX技术最大的问题在于性能上的坑,因此基于这种方案的补丁技术影响应用的性能是无疑的。

1. 启动加载时间过长

我们可以看到,超级补丁技术和Tinker都选择在Application的attachBaseContext()进行补丁DEX的加载,即使这是加载dex的最佳时机,但是依然会带来很大的性能问题,首当其冲的就是启动时间太长。

对于补丁DEX来说,应用启动时虚拟机会进行dexopt操作,将patch.dex文件转换成odex文件,这个过程非常耗时。而这个过程,又要求需要在主线程中,以同步的方式执行,否则无法成功进行修复。就DEX的加载时间,大概做了以下的时间测试。

024

随着patch.dex的增加,在不做任何优化的情况下,启动时间也直线增长。对于一个应用来说,这简直是灾难性的。

2. 易造成应用的ANR和Crash

正是尤其多DEX加载导致了启动时间过长,很容易就会引发应用的ANR。我们知道当应用在主线程等待超过5s以后,就会直接导致长时间无响应而退出。超级补丁技术为保证ART不出现地址错乱问题,需要将所有关联的类全部加入到补丁中,而微信Tinker采取一种差量包合并加载的方式,都会使要加载的DEX体积变得很大。这也很大程度上容易导致ANR情况的出现。

除了应用ANR以外,多DEX模式也同样很容易导致Crash情况的出现。我们知道,超级补丁技术为了保证ART设备下不出现地址错乱问题,需要把修改类的所有相关类全部加入到补丁中,这里会出现一个问题,为了保证补丁包的体积最小,能否保证引入全部的关联类而不引入无关的类呢?一旦没有引入关联的类,就会出现以下的异常:

  • NoClassDefFoundError
  • Could not find class
  • Could not find method

出现这些异常,就会直接导致应用的Crash退出。

所以,不难看出如果我们需要修复一个不是Crash的BUG,但是因为未加入相关类而导致了更严重的Crash,就更加的得不偿失。

总的来说,热修复本质的目的是为了保证应用更加稳定,而不是为了更强大的功能引入更大的风险和不稳定性。

二、热修复 or 插件化?

我们经常提到热修复和插件化,这都是当下热门的新兴技术。在讲述之前,需要对这两个概念进行一下解释。

  • 插件化:一个程序划分为不同的部分,以插件的形式加载到应用中去,本质上它使用的技术还是热修复技术,只是加入了更多工程实践,让它支持大规模的代码更新以及资源和SO包的更新。
  • 热修复:当线上应用出现紧急BUG,为了避免重新发版,并且保证修复的及时性而进行的一项在线推送补丁的修复方案。

显然,从概念上我们可以看到,插件化使用场景更多是功能,热修复使用常见在于修复。从这个层面来说,插件化必然功能更加强大,能做的事情也更多。QQ空间超级补丁技术和微信Tinker从类、资源的替换和更新上来看,与其说是热修复,不如说是插件化。

当然,强大的功能也就增加了不稳定的因素。比如上文提到的增加启动时间,导致ANR、Crash的问题。

QQ空间超级补丁技术和微信Tinker提供了更加强大的功能,但是对应用的性能和稳定有较大的影响,就BUG修复的这个使用场景上还不够明确,并且显得过重。

针对应用的性能损耗,我们可以举例做一个对比。

某APP的启动载入时间为3s左右,本身就是基于多DEX模式的实现。

分别接入三种热修复服务,根据腾讯提供超级补丁技术和Tinker的数据,那么会变成以下的场景:

  1. 阿里百川HotFix:启动时间几乎无增加,不增加运行期额外的磁盘消耗。
  2. QQ空间超级补丁技术:如果应用有700个类,启动耗时增加超过2.5s,达到5.5s以上。
  3. 微信Tinker:假设应用有5个DEX文件,分别修改了这5个DEX,产生5个patch.dex文件,就要进行5次的patch合并动作,假设每个补丁1M,那么就要多占用7.5M的磁盘空间。

显然对于修复紧急BUG这个场景,阿里百川HotFix的更为合适,它更加轻量,可以在不重启的情况下生效,且对性能几乎没有影响。微信Tinker、QQ空间超级补丁技术更多地把场景定位在发布小的新功能上,采用ClassLoader的模式,牺牲较高的性能代价去实现类、资源新增或替换的功能。阿里百川HotFix对应用本身做到无侵入,无性能损耗。

总结

QQ空间超级补丁技术和微信Tinker 支持新增类和资源的替换,在一些功能化的更新上更为强大,但对应用的性能和稳定会有的一定的影响;阿里百川HotFix虽然暂时不支持新增类和资源的替换,对新功能的发布也有所限制,但是作为一项定位为线上紧急BUG的热修复的服务来说,能够真正做到BUG即时修复用户无感知,同时保证对应用性能不产生不必要的损耗,在热修复方面不失为一个好的选择。

目前阿里百川HotFix已经开始公测,点击立即使用,即可开始你的热修复之旅。