一个SDK的打包过程到底要做哪些优化?

1,518 阅读9分钟

本来计划五一约上几个朋友去一趟青岛的,吃蛤蜊喝啤酒,去感受下青岛人民的热情。 实在是没想到,最近国内疫情反复,没去成...

最近露营比较火,那就找个人少的地方烧烤吧,五一转了一大圈,开放的河边不让烤,让烤的河边封闭了。

不但没去上青岛,没烤上串,我们还居家办公了...

image.png

最近的股票也是这样的...所以我卸载了股票App,基本算是躺平了


最近公司的项目也是非常的忙,因为是0-1的项目,所以很多基建的工作需要做,今天把基建过程中编译打包的一部分总结了一下,给大家分享。

项目结构

我目前在做一个三方的SDK,我们的项目结构主要由2个SDK和一个Demo组成

image.png

  • 由于提供给三方使用,因此打包上传的链路跟集团的2方SDK链路就完全不同了

  • 三方SDK需要把包上传到Maven中心仓库,三方可以访问到的位置。

  • 具体的maven上传链路可以看这篇文章juejin.cn/post/707010…

最终都是产出一个包,为什么还需要打包方案?

image.png

公司打包的链路

公司打包有一个统一的打包平台,类似外部经常使用的Jenkins,打包链路相对比较清晰,主要有以下几步。

  • 1.首先注册我们需要打包的应用。

  • 2.配置我们打包的流水线,流水线就好比打包的执行链路

  • 3.在流水线添加具体的工作环节,Jenkins通过shell也可以实现,比如首先通过git拉取代码分支,然后执行我们的打包脚本,最后进行产物上传。

  • 4.如果有其他自动化诉求可自定义添加,比如各种卡口,包大小检查,单元测试,风险/合规测试等。

  • 5.创建一个打包单,填写本次打包的版本,代码分支,选择对应的流水线即可。Jenkins可以通过代码配置文件实现该功能,也可以做定制页面去执行该操作。

我们要做什么?

在以上思路完成后,我大概列了下我们需要做的事

  • SDK

image.png

  • Demo

同样需要动态依赖,本地打包全部依赖SDK源码,打包机打包需要通过下放参数动态引用SDK的Snapshots AAR,Release AAR。

整体打包细节拆解

参数下发/解析

  • 首先我先普及下传参思路,打包开始前,需要从打包平台下发当前打包的参数,我们可以通过脚本将所需参数暂存到环境中,编译时通过暂存的KEY获取即可。
//SDK gradle可通过这种方式获取相应参数
def sdkVersion = java.lang.System.getenv("VERSION_NAME")
  • 当然为了便捷,也可以将数据生成一个Json文件,存到项目固定目录,打包开始前首先读取对应Json,将相关参数做解析,并动态使用,如下所示。
{
  "other": [
    "buildType":"Snapshots",
  ],
  "dependencies": [
    {
      "artifact": "CoreSDK",
      "type": "AAR",
      "version": "1.0.0",
      "group": "com.sdk.core"
    },
    {
      "artifact": "UISDK",
      "type": "AAR",
      "version": "1.0.0",
      "group": "com.sdk.ui"
    }
  ],
  "exclude": []
}
  • Android工程可通过JsonSlurper进行Json解析
import groovy.json.JsonSlurper
def buildJson = file('../buildJson.json')
def jsonSlurper = new JsonSlurper()
def jsonObject = jsonSlurper.parse(buildJson)

根据参数动态引用

   if (jsonObject.other.buildType == 'Snapshots' 
       ||jsonObject.other.buildType == 'Release') {
        println("implementation 引用SDK aar")
        implementation(group: jsonObject.dependencies[0].group, name: jsonObject.dependencies[0].artifact, version:jsonObject.dependencies[0].version)
        implementation(group: jsonObject.dependencies[1].group, name: jsonObject.dependencies[1].artifact, version:jsonObject.dependencies[1].version)
    } else {
        println("implementation 引用SDK工程")
        implementation project(':UISDK')
    }

构建产物动态上传

//这个值是从mtl下发的
if (jsonObject.other.buildType == 'Snapshots') {
    apply from: './upload_snapshot.gradle'
} else if (jsonObject.other.buildType == 'Release') {
    apply from: './upload_release.gradle'
} else {
    apply from: './upload_local.gradle'
}

打包

以上普及完成,我们开始打包。

  • 1.打包CoreSDK

  • 2.打包UISDK,一切都顺利。

  • 3.打包引用SDK的Demo,报错了。

Multiple dex files define com.a.ds.a: Multiple dex files define Lcom/a/ds/aa.class

这个问题当时感觉有点奇怪,明明没有重复的包名类名,为什么会编码报错类冲突呢?

后来发现,我们编译用的混淆文件proguard-rules.pro基本都是默认配置,默认配置在混淆过程中,会通过默认的abcdefg这种顺序的字母组合去替换我们代码中自定义的类名,接口名,方法名,以及变量名,以此来达到混淆的目的。

这里插一句,其实经常有人有误区,以为混淆打包就是为了防止被反编译,其实不然

  • 混淆只是将我们自定义的各种代码名字进行了重命名,将名称意义抹除,替换成了abcd这种无任何意义的名字,同时也把未引用的代码移除。
  • 所以混淆只能提高代码的安全性,增加反编译的后代码的阅读成本,同时也会缩减包体积。

如果混淆都使用默认的配置,那其实是有一定概率产生混淆后的类冲突,比如混淆后都叫com.a.ds.aa.class,此时可以通过自定义混淆字典实现,混淆后的代码即可规避冲突风险。

-obfuscationdictionary filename.txt
  • filename.txt
coreu_do
coreu_if
coreu_for
coreu_int
coreu_new
coreu_try
coreu_byte
coreu_case
coreu_char
coreu_else
coreu_goto

提测

一顿操作结束,Demo打包成功,并通过脚本上传到了服务器,通过Demo下载地址产出一个二维码,将二维码提测给QA团队,测试异常打回,测试成功发版。

发版

  • 1.发版的打包过程与测试打包环节类似,只是构建产物上传地址差异。

  • 2.发版前会强制代码review,如果不review无法打包发布。

  • 3.代码review结束会将dev开发分支合并到master分支,进行代码统一管理。

  • 4.发布后的代码会通过tag进行打标,记录当前发布的版本。

  • 5.版本发布成功,会打一个回归包进行简单回归。

  • 6.如果有渠道包诉求可以在这个环节产出。

回归

如果回归测试发现问题,需要按情况分析,如果涉及功能阻塞,需要先将功能开关关闭(每个功能都有开关),其次加发修复包,修复包在技术流程跟发正常版本一样,在管理流程需要抄送高级别领导知晓,并在问题解决后一周内进行复盘。

归档

版本发布结束后,打包单会冻结,并打上已发版的标签,后续版本迭代重新拉取代码分支,重新创建打包单。

打包过程优化

作为三方使用SDK,有3个硬指标

image.png

包体积

包体积肯定是越小越好,这里我补充一个包体积优化的3个核心做法

  • 1.删减,压缩图片资源

  • 2.转换图片资源webp格式

  • 3.资源混淆

删减资源

想删减无效资源,首先得知道哪些资源无效,目前通用做法一般有2种。

  • 1.编译过程可以通过lintOptions标签配置资源检测,通过./gradlew lint触发检测task,通过产出的lint-results.html进行删减资源
lintOptions {
    check "UnusedResources" //只检查无用资源"UnusedResources","UnusedIds"
    checkDependencies true //对依赖的资源也执行检查,注意也会对引用aar检查
}
  • 2.通过字节的开源框架ByteX可以更深层的检测无用assets/Resource,具体使用可以看这个 github.com/bytedance/B…
resCheck {
    enable true // 无用资源检查的开关
    // 根据资源所在的路径做模糊匹配(因为第三方库用到的冗余资源没法手动删)
    onlyCheck = [
            // 只检查主工程里的资源
            "app/build",
            "business-support/build",
            "dynamic/build",
            "home/build",
            "search/build",
            "stepping-stone/build"
    ]
    // 检查白名单。这些资源即便是冗余资源也不会检查出来
    keepRes = [
            "R.dimen",
            "R.color",
            "R.animator",
            "R.integer",
            "R.bool",
            "R.style",
            "R.styleable",
            "R.attr",
            "R.xml",
            "R.array",
            "R.string"
    ]
}

压缩图片

  • 我们很多的资源图片是需要打包带进去的,基本图片资源压缩会是优化体积的重要手段。

  • 一般UI或视觉工程师给我们的图都是原图,我们可以通过比较有名的tinyping平台做图片压缩,但是tinyping压缩算法没有开源,也有压缩数量限制,并且对已经在的资源还需要一个个拿出来压缩,无法自动化。

  • McImage是一个比较成熟的压缩插件,可参考github.com/smallSohoSo…,对多个位置的图片资源都可以进行压缩,压缩算法开源。

压缩范围

  • Jar包中的图
  • AAR中的图
  • 子Module中的图

压缩算法

使用方式

McImageConfig {
  isCheck true //default true
  isCompress true //default true
  maxSize 1*1024*1024 //default 1MB 
  maxSize 60 * 1024 //大图片阈值,default 100KB
  enableWhenDebug false //debug下是否可用,default true
  isCheckPixels true // 是否检测大像素图片,default true
  maxWidth 2000 //default 1000 如果开启图片宽高检查,默认的最大宽度
  maxHeight 2000 //default 1000 如果开启图片宽高检查,默认的最大高度
  whiteList = [ //默认为空,如果添加,对图片不进行任何处理
                  "ic_launcher.png"
            ]
  mctoolsDir "$rootDir"
  multiThread false  //是否开启多线程处理图片,default true
  bigImageWhiteList = [ // 对上层业务中加入的大图,增加白名单
            "big.png"
    ]     
}
  • 编译过程中,pngquant 压缩 png 图片,guetzli 压缩 jpg 图片, 并替换
[Compress][/home/admin/.emas/build/13314914/workspace/stepping-stone/build/intermediates/packaged_res/release/drawable-xxhdpi-v4/share.png][oldInfo: 493][newInfo: 307]
[Compress][/home/admin/.emas/build/13314914/workspace/stepping-stone/build/intermediates/packaged_res/release/drawable-xxhdpi-v4/default.png][oldInfo: 16510][newInfo: 1031]

转换图片资源webp格式

  • 在图片压缩的基础上,还可以做进一步的转化处理,将png无损转换为webp,webp有格式优势,具备更优的压缩率.
  • 该转换存在内部逻辑,如果转换后体积增大则不使用,转换后体积减少则替换原格式。但是需要注意Android在4.3以上系统才完全支持webp格式
McImageConfig {
    optimizeType "ConvertWebp"
    isSupportAlphaWebp true
}
[Webp][/home/admin/.gradle/caches/transforms-2/files-2.1/49e45938bdf3a608d0bb31bae299e270/res/drawable-xhdpi-v4/check.png][oldInfo: 423][newInfo: 324]
[Webp][/home/admin/.gradle/caches/transforms-2/files-2.1/801ea15ef7c3dc1dea3117fae865b2bc/res/drawable-mdpi-v4/light.png][oldInfo: 310][newInfo: 292]
[Webp][/home/admin/.gradle/caches/transforms-2/files-2.1/432f7fc4743a824d19b5b2652e87345e/res/drawable-hdpi-v4/location.png][oldInfo: 1204][newInfo: 650]
[Webp][home_arrow.png] do not convert webp because the size become larger!

资源混淆

  • 与代码混淆类似,资源同样可以做混淆,目前相对成熟的方案可以使用AndResGuard github.com/shwenzhang/…

  • 这个一般用于Apk压缩(AAR需要改动),我们需要提供打包完成的Apk,然后将签名删除,再将Apk解压,然后将资源按abcdefg这种顺利进行重命名,抹除原有名称,同时将arsc索引表与之对应修改,完成之后重新进行打包,整个过程不会牵扯编译过程,而是在Apk编译成功之后增加的一个处理任务,对其资源进行混淆重排,最后可通过7z压缩算法对apk进行整体压缩。

长效机制

  • 随着版本的迭代开发,增加编译卡口,每次编译对新增资源做体积检测,发现图片超过阈值,则直接终止编译,报错指向增量较大的资源,提示修改压缩后再进行编译集成。必须有这种卡口机制才是长效的保证