从大团队并肩作战到小团队带头冲锋,苏宁App插件化应用实践
简介
从大团队并肩作战到小团队带头冲锋,高效的研发模式使得 App 本身的整体崩溃率始终维持在 0.02% 以下。
本着以用户为中心、以开发者为出发点,根据现有开源方案取长补短,苏宁易购移动开发部于 2017 年初自主研发出了新型插件化技术——APNP(Android Plugin And Play),旨在让研发更敏捷,让发布更灵活,最终满足用户对产品的极速体验、按需下载、动态更新。
需求分析
技术的引入来自于实际业务场景对技术的需求,插件化亦是如此,那么到底是什么原因推动了苏宁易购 App 的插件化,又是什么原因让苏宁易购开发者走上自研插件化的道路?
为什么苏宁易购 App 需要插件化?
发布周期长,产品迭代跟不上市场需求
对于一个电商 App,不同的时间、地点,伴随着用户千变万化、稍纵即逝的消费需求,谁能在第一时间满足这些需求,谁就能把握住需求带来的销量。
而传统的 App 开发模式周期过长,我们需要更敏捷的发布方案,所以我们做了插件化,这也是苏宁易购对插件化最原始的需求。
单线研发,管理、协作成本过高
随着苏宁易购业务的不断拓展、项目参与人员数量的增多,单线 App 开发模式所隐藏的问题日益凸显:一面从需求分析到研发测试,需要监管的内容越来越多;另一方面从方案决策到流程审批,协作沟通越来越频繁,成本越来越高。
因此我们需要多线、小团队的研发模式,这让我们进一步确认了插件化。
安装包过大,运营推广效率走低
需求在日益增长的同时,安装包体积也在同步膨胀。面对耗时、耗流量的安装包下载,新用户体验、老用户升级的阻力也越来越大。
为了解决这个问题,我们需要拆包、需要动态下载、需要局部更新,因此我们正式引入了插件化。
什么选择自研而不是使用开源方案?
没有完美无暇的开源方案,却有层出不穷的接入问题
移动团队在一开始选择过几种开源方案,个别方案的可用性也比较高,但是在接入之后,测试环节总会出现些疑难杂症,修复起来相对困难,一方面是源码本身的掌握成本较高,另一方面就是开源方案本身存在的缺陷。
要么另起炉灶,要么藕断丝连
现有的开源方案,要么就是对现有工程的改造较大,开发有心无力;要么就是插件工程和宿主工程相互依赖,牵一发而动全身,成本、风险都很高。
插件方案选型
基于上述种种原因,我们决定自研插件化技术,于是就有了 APNP。APNP采用的插件方案是直接加载 APK 文件(APK 格式不变,内容有所修改),原因有如下几点:
兼容性高
APK 文件的格式非常稳定,它包含一个 App 正常运行的必要资源;任何版本的 Android 系统,都应该正常解析并运行任意版本匹配的 APK 文件,无论这个 APK 文件是何时产生的;我们只要正确模仿系统加载 APK 的行为,就可以加载任意一个插件 APK 文件。
研发隔离
既然是直接加载 APK 文件,所以在 APNP 的设计方案中,每个插件都是一个单独工程,这就意味着除了最后的集成测试阶段,插件对应的整个软件生命周期都是独立的,既降低了管理、协作成本,又促使插件产品的发布更加灵活。
接入简单
既然是独立工程,无论现有的工程是如何运转的,开发者唯一要做的就是把插件工程从现有工程中抽离出来。
核心手段及原理
如果直接加载一个原始插件的 APK 文件,绝大多数情况下是无法运行的,如 Class pre-verified 异常、ResourceNotFound 等。
因此 APNP 在保持 APK 格式不变的情况下,对APK里面的内容做了针对性修改,核心手段如下:
共同 Dependency 剔除
在研发过程中,经常需要第三方依赖 Library(Dependency),而当插件工程(以下简称 Plugin)和宿主工程(以下简称 Host)包含相同的 Dependency 时,就需要剔除 Plugin 中的 Dependency。
这样一方面可以避免 Class pre-verified 异常,另一方面也可以减少插件包的大小,提升插件包的下载 & 加载速度。
实现方案(以下都默认 IDE 为 Android Studio):通过 Gradle 插件,在 Plugin 的 Transform 过程中,剔除与 Host 相同 Dependency 的所有资源。
Package ID 修改
在 Android 系统中,App 对应的 Package ID 是固定的,也就是说如果我们不人为干预,Plugin 和 Host 打包生产的 APK 文件中,所有 Resource ID 中的 Package ID 都是一样的,即(0x7f)。
这时如果直接加载 Plugin APK,必然会出现 Resource 类型不匹配、显示错乱等异常,所以我们修改 Plugin 的 APK 的 Package ID。(Package ID 满足 0x01 < PID < 0x7f 即可)
实现方案:业内关于修改 Package ID 的方案有很多(如修改 aapt 源码等),APNP 采用的方案是直接修改最后生成的 APK 文件。
先看下一个简单 APK 的文件结构,如下图:
涉及 Package ID 修改的地方有 3 处:
- R.class(classes.dex)
- resources.arsc
- xml 文件
修改 R.class 中的 Package ID
在 Gradle assemble 之后,会在 build/generated/source/r 文件下生成相应的 R.java 文件,如下图:
此时我们将 R 文件中的 0x7f 直接替换成目标 Package ID ,然后继续交给 Gradle 做后续操作。
这样在最终的 classes.dex 中,里面的 R.class 文件对应的 Package ID 就是我们想要设置的 Package ID。
修改 arsc + xml 中的 Package ID
不同于上面通过 Hook Gradle 过程修改中间生成的 R 文件,arsc + xml 则是直接解压最终的 APK 并修改目标文件,然后重新签名。
首先我们需要对 APK 文件中的 arsc + xml 的文件格式有一定的认识,Android 为了充分减少 APK 自身的大小,在编译的过程中会对所有的资源进行重组 + 压缩。
例如 values 文件下的内容会被统一收集到 arsc 文件中,而不再以文件的形式存在;而 xml 也不再是原始的 xml 文件,xml 中内容会被进一步整合 + 复用。
而无论是 arsc 文件,还是 xml 文件,在它们内部都是通过一个个固定数据结构的 ResChunk 以嵌套 + 组合的形式各自存储着。
资源信息:每段 ResChunk 的内容都以 8 字节的 ResChunk_header 开头,用于描述 ResChunk 的类型 + 长度,如下图:
也就是说 arsc + xml 中的所有内容都可以被反向解析出来,当充分了解每个 ResChunk 的数据结构以及掌握 ResChunk 中哪些内容是需要修改的 Package ID,就可以相对轻松的完成对二进制文件的修改。
Library Chunk 插入
修改完 Package ID 后,还需要在 resources.arsc 文件中插入一段 Library Chunk ,如下图:
Library Chunk 在 Android 5.0 之后才出现,对应的类型如下图:
那么为什么需要插入 Library Chunk?原因是在 Android 5.0 之后,ResTable在获取资源信息时,如果资源含有 Parent(ResTable_map_entry,如 style),会验证 Parent 的 Package ID 是否合法,也就是 Package ID 是不是已经注册过了。
如果 lookupResourceId 没有返回 NO_ERROR,则报错,继续跟进,如下图:
由于在上一环节中,我们已经把 Plugin 中的 Package ID 修改了,上图中的局部变量 packageId 肯定不会是 APP_PACKAGE_ID 。
最后会在 mLookupTable 数组中寻找 packageId 是否有对应的值,如果没有(值为 0)则抛出异常。
所以我们需要把自定义的 Package ID 注册到 mLookupTable 数组中,那么注册的动作是在什么时候发生的?
在 App 启动并资源首次加载时,会调用下面的方法去解析 arsc 文件,如下图:
如果发现是 Library Chunk 会调用 addMapping,如下图:
在 addMapping 中,我们会把 Library Chunk 中的 packageId 在 mLookupTable 数组中进行注册(就是简单的赋值),注册之后我们自定义的 Package ID 才会被系统认可,从而确保含有 Parent 资源的正常解析。
Attr ID 替换
完成上面 3 步后,APK 就已经可以加载了,但是插件涉及到自定义属性的 View ,自定义属性总是无法正常赋值,这又是什么原因呢?
以 ConstraintLayout 为例,假如 Plugin 和 Host 的 Layout 布局中都包含 ConstraintLayout,并同时都设置了 ConstraintLayout 的自定义属性如下:
那么在 App 运行的过程中,ConstraintLayout 会通过 android.support.constraint.R.class 获取解析自定义属性,如下图:
而 android.support.constraint.R.class 在内存中只有一份,并且会优先加载 Host,因此在实际的运行内存中,R.class 来自于 Host。
由于 Host 中 R.class 里面的 Package ID 都是 0x7f ,而在上面第二节的修改 Package ID 环节中,Plugin 里面的 xml 的 Package ID 已经被我们修改了(包括 xml 中所引用的 Attr ID)。
因此当 Plugin 通过 Host 的 R.class 去解析 ConstrainLayout 时,必然会出现自定义属性无法正常解析的异常。
所在 APNP 在修改 Plugin xml 文件 Package ID 的同时,如果发现 Attr 同样存在于 Host 中,会把 Plugin xml 文件中的 Attr ID 替换成 Host 中的 Attr ID,从而保证 Plugin 中所有的自定义属性能够正常解析(红框:0x7f01000f,0x7f010022,0x7f010026,0x7f01002b),如下图:
否则直接修改 Package ID(蓝框:0x7e010000,0x7e010001,0x7e010002)。
实践成果
在 APNP 实际应用之后,通过苏宁云迹平台的实时数据监控,并没有发现因 APNP 本身引起的明显异常。
从大团队并肩作战到小团队带头冲锋,高效的研发模式也使得 App 本身的整体崩溃率始终维持在 0.02% 以下。
相比之前的研发流程,APNP 带来的影响主要有下面几个点:
产品更精细、定位更明确
APNP 是产品精细划分和明确定位的技术支撑,小团队的研发模式,加快了产品的落实与升级。
研发周期短、发布更灵活
插件可单独升级、可独立发布的特征,配合独立团队的专人专职,单一产品可以达到一周一版本。
管理更轻松、研发更高效
同步产品线的精细划分,研发团队也趋于更小、更独立,管理也显得更加轻松;同时明确的责任分工,也促进了研发人员的工作效率。
发展前景
Google 在今年的 IO 大会上推出了一种动态加载方案——App Bundles,可以让用户通过 Google Play 动态下载应用功能,而且 App Bundles 采用的方案也是直接加载 APK 文件。
通过分析最终的 Feature Master APK 文件,你会发现 App Bundles 对 APK 做了和 APNP 相同的操作:剔除 Dependency、修改 Package ID、插入 Library Chunk、替换 Attr ID。
这看似巧合实则必然,因为 App Bundles 和 APNP 都是直接加载 APK 文件,所以面对的问题都是一样的。
在方案上 APNP 可以说有了官方保障,而且 APNP 在后面还可以借鉴 App Bundles 的实现细节,让插件加载更加高效、安全。
而由于 Android P 对隐藏 API 调用的限制,很多人认为插件化方案将不再可行。
但是 Google 也同时提供了 light greylist,并且国内手机厂商肯定不允许让主流 App 无法使用的情况发生,所以我觉得在很长一段时间内,插件化依然可以正常运转。
作者:李呈武