前言
目前在线打包系统虽然能够满足渠道分包需求,但是还存在很多问题,比如操作复杂度高、游戏渠道关联和渠道信息配置入口不统一、母包需要上传打包机器打完后还要下载(包体积上 G)、多个用户公用一个打包机器,基于上述原因开发了一款桌面打包工具。
技术选型
由于我们开发者使用的是 Mac 电脑,而用户(运营或者游戏提供商)使用的是 Windows 电脑,因此两个平台都需要兼顾到,最初考虑是 Windows 版本使用 Java Swing 进行开发,Mac 版本使用原生进行开发,但是这样势必要写两份代码,那么有没有一份代码就可以同时在两个平台上运行的方法呢?通过查阅资料发现 Electron 可以满足这个要求,其使用前端技术(Vue、React)进行开发,一份代码可以同时编译出 dmg 以及 exe,同时 VisualStudioCode 也是使用该技术进行开发的,最终就选定使用该方案,由前端同事负责页面绘制,我负责逻辑处理。
界面展示
大体上主要分为登录、游戏配置、渠道配置、打包四个界面。
登录界面
游戏配置界面
渠道配置界面
打包界面
基本原理
整体架构图如下:
整体时序图如下:
打包原理
JS 层主要完成资源准备,比如下载闪屏图、渠道插件 SDK 资源等,以及结果处理,然后调用 Kotlin 进行打包,在打包过程中会适时的写文件更新进度,JS 只需开启一个定时器一直读取进度即可,等到命令执行完成,判断下进度是否为 100,即可判断打包是否成功。这里只关注下打包流程。
注:原先采用 Python2 编写,我改为使用 Kotlin 编写,因为我感觉 Python 执行速度没有 Kotlin 快,同时 Kotlin 语法糖也多,不像 Java 那么慢。
打包流程如下(参考 U8 SDK):
反编译母包。
/**
* 反编译 APK
* -v => verbose Verbose output. Must be first parameter
* -f => force Force delete destination directory
* --only-main-classes => Only disassemble dex classes in root (classes[0-9].dex)
*/
fun decompileApk() {
val command = "${apkToolPath()} d -f $baseApkPath --only-main-classes -o ${decompileDir.path}"
executeJava(command)
}合并 smali 文件。
注:N 从 2 开始,反编译后第一个 dex 生成的 smali 文件会放到 /smali 文件夹下,第二个放到 /smali_classes2 下,以此类推,这步用于合并所有 smali 文件,后续会进行拆分。
fun mergeMultiDex() {
val targetDir = File(decompileDir, "smali")
var startIndex = 2
var smaliDir = File(decompileDir, "smali_classes$startIndex")
while (smaliDir.exists()) {
smaliDir.copyRecursively(targetDir, false)
smaliDir.deleteRecursively()
startIndex++
smaliDir = File(decompileDir, "smali_classes$startIndex")
}
}删除测试渠道资源,为了便于 CP 测试因此提供了测试渠道,打渠道包需要进行删除。就是把注册的 Activity,资源以及配置文件删除。
fun deleteDebugRes() {
val devConfigFile = File(decompileDir, "/assets/eu_developer_config.properties")
val pluginConfigFile = File(decompileDir, "/assets/eu_plugin_config.xml")
val manifestFile = File(decompileDir, "AndroidManifest.xml")
val checkFileList = arrayOf("colors", "dimens", "ids", "public", "strings", "styles")
for (checkFile in checkFileList) {
val file = File(resDir, "values/$checkFile.xml")
if (file.exists()) {
deleteDebugValues(file)
}
}
resDir.walkTopDown()
.filter { it.isFile && it.name.startsWith("x_") }
.forEach { it.delete() }
deleteActivityRegister(manifestFile)
devConfigFile.delete()
pluginConfigFile.delete()
}
private fun deleteActivityRegister(manifestFile: File) {
val factory = DocumentBuilderFactory.newInstance()
val document = factory.newDocumentBuilder().parse(manifestFile)
val xpathFactory = XPathFactory.newInstance()
val xpath = xpathFactory.newXPath()
val expression = xpath.compile("//activity[starts-with(@name,'com.eu.sdk.impl.activities')]")
val nodeList = expression.evaluate(document, XPathConstants.NODESET) as NodeList
for (i in 0 until nodeList.length) {
nodeList.item(i).apply {
parentNode.removeChild(this)
}
}
document.saveToXml(manifestFile)
}重命名包名。
注:根据配置将 AndroidManifest.xml 中 manifest 节点下 package 值改为指定的值,对于组件名以 . 开头的需要替换为原包名,防止类找不到。
fun renamePackage() {
val document = manifestFile.parseXml()
val nodeList = document.getElementsByTagName("manifest")
if (nodeList.length != 1) {
"manifest tag count ${nodeList.length}".fatalLog()
}
val packageNameNode = nodeList.item(0).attributes.getNamedItem("package")
val oldPackageName = packageNameNode.nodeValue
val activityNodes = Nodes(document.getElementsByTagName("activity"))
val serviceNodes = Nodes(document.getElementsByTagName("service"))
val receiverNodes = Nodes(document.getElementsByTagName("receiver"))
val providerNodes = Nodes(document.getElementsByTagName("provider"))
for (node in activityNodes) {
val activityNameNode = node.attributes.getNamedItem("android:name")
var activityName = activityNameNode.nodeValue
if (activityName.startsWith(".")) {
activityName = "$oldPackageName.$activityName"
}
activityNameNode.nodeValue = activityName
}
for (node in serviceNodes) {
val serviceNameNode = node.attributes.getNamedItem("android:name")
var serviceName = serviceNameNode.nodeValue
if (serviceName.startsWith(".")) {
serviceName = "$oldPackageName.$serviceName"
}
serviceNameNode.nodeValue = serviceName
}
for (node in receiverNodes) {
val receiverNameNode = node.attributes.getNamedItem("android:name")
var receiverName = receiverNameNode.nodeValue
if (receiverName.startsWith(".")) {
receiverName = "$oldPackageName.$receiverName"
}
receiverNameNode.nodeValue = receiverName
}
for (node in providerNodes) {
val providerNameNode = node.attributes.getNamedItem("android:name")
var providerName = providerNameNode.nodeValue
if (providerName.startsWith(".")) {
providerName = "$oldPackageName.$providerName"
}
providerNameNode.nodeValue = providerName
}
packageNameNode.nodeValue = packageName
document.saveToXml(manifestFile)
}重命名应用名。
fun renameAppName() {
val document = manifestFile.parseXml()
val appNodes = document.getElementsByTagName("application")
if (appNodes.length > 0) {
appNodes.first().attributes.getNamedItem("android:label")?.nodeValue = gameName
}
document.saveToXml(manifestFile)
}处理渠道和插件 SDK 资源。
fun handleChannelSdk() {
channelSdkDir.copyRecursively(channelSdkTempDir, true) // 拷贝资源到临时目录
handleSdk(channelSdkTempDir)
}
private fun handleSdk(sdkDir: File) {
val sdkAssetsDir = File(sdkDir, "assets").orCreateDir()
val sdkLibsDir = File(sdkDir, "libs").orCreateDir()
val sdkManifestFile = File(sdkDir, "SDKManifest.xml")
handleSdkAar(sdkDir) // 取出渠道中的 aar 文件
parseProxyApplication(sdkManifestFile) // 解析 proxyApplication 给渠道在 Application 生命周期中执行代码的机会
copyAssetsInJars(sdkDir, sdkLibsDir, assetsDir) // 拷贝 jar 包中的 assets 目录
convertJar2Dex(sdkDir, sdkDir, sdkLibsDir) // 将 jar 包转换为 dex 借助 d8
convertDex2Smali(sdkDir, smaliDir) // 将 dex 文件转换为 smali 借助 bakSmali
mergeManifest(manifestFile, sdkManifestFile) // 合并清单文件
copyNativeSo(sdkLibsDir, libDir) // 拷贝 so 文件
copyAssets(sdkAssetsDir, assetsDir) // 拷贝 assets 目录
}拷贝 SDK 资源到临时目录 sdkTemp。
处理 SDK 资源目录下所有的 aar 文件,全部合并到 sdkTemp。注:渠道资源优先级小于母包。
处理 aar 文件。
合并 AndroidManifest.xml 文件。
合并 assets 文件夹。
合并 libs 文件夹。
合并 jni 文件夹。
合并 res 文件夹。
- 对于 values (values-xxx)以外的文件夹,直接简单的拷贝替换即可。
- 对于 values(values-xxx)文件夹,则需要读取目录下所有文件内容。去重后将其整合为一个values.xml 文件,同时需要考虑到 declare-styleable 元素不能简单的去重,而要对其内部含有 format 属性的 attr 元素进行合并。
拷贝 classes.jar 到 sdkTemp/libs 下,需要加上前缀。注:生成 aar 时会将内部代码打成 classes.jar。
标记是否需要生成该库的 R 文件。
解压 sdkTemp/libs 目录下所有的 jar 包,将解压后 jar 包中的 assets 文件夹与 sdkTemp/assets 合并。注:jar 包中也可能会有 assets,所以需要解析进行合并。
使用 d8 将 sdkTemp/libs 以及 sdkTemp/下所有的 jar 包编译成 classes.dex 文件。注:可能还会有 classes2.dex 等。
使用 baksmali 将上一步生成的 dex 文件编译成 smali 文件夹,并将其拷贝到 decompile/smali 目录下。
将 sdkTemp/SDKManifest.xml 与 decompile/AndroidManifest.xml 进行合并。
将 sdkTemp/libs 下所有文件(除 jar、aar,因为已被转换为 smali 拷贝过去了)与 decompile/libs 进行合并。
将 sdkTemp/assets 下所有文件与 decompile/assets 进行合并。
这里主要是处理 aar 文件比较麻烦,处理过程如下:
fun handleSdkAar(sdkDir: File) {
val manifestFile = File(sdkDir, "SDKManifest.xml")
val assetsDir = File(sdkDir, "assets").orCreateDir()
val libsDir = File(sdkDir, "libs").orCreateDir()
val resDir = File(sdkDir, "res").orCreateDir()
sdkDir.walkTopDown() // 遍历找到所有 aar 文件
.filter { it.isFile && it.extension == "aar" }
.forEach {
mergeSdkAar(it, manifestFile, assetsDir, libsDir, resDir)
}
}
fun mergeSdkAar(aarFile: File, manifestFile: File, assetsDir: File, libsDir: File, resDir: File) {
AarMerger(aarFile).merge(manifestFile, assetsDir, libsDir, resDir)
}
fun merge(manifestFile: File, assetsDir: File, libsDir: File, resDir: File) {
unzip(aarDir, aarFile) // 解压到临时目录
mergeManifest(manifestFile, File(aarDir, NAME_MANIFEST_FILE)) // 合并清单文件
mergeAssets(assetsDir, File(aarDir, NAME_ASSETS_DIR)) // 合并 assets
mergeLibs(libsDir, File(aarDir, NAME_LIBS_DIR), aarFile) // 合并 libs, classes.jar(aar 中所有类都在这)
mergeJni(libsDir, File(aarDir, NAME_JNI_DIR)) // 合并 jni
mergeRes(resDir, File(aarDir, NAME_RES_DIR)) // 合并 res
}核心其实还是合并 manifest、res 的合并流程,这个在文章最后进行总结。
处理占位符信息。注:比如微信回调类名固定要是 包名.WXEntryActivity,就可以使用该占位符。
fun handlePlaceHolders() {
modifyFileContent(manifestFile, "${'$'}{applicationId}", packageName)
}
fun modifyFileContent(file: File, oldValue: String, newValue: String) {
file.writeText(file.readText().replace(oldValue, newValue)) // 简单的全部读出来,替换后写进去
}处理 so 文件。注:删除无用的 so,确保 armeabi 资源与 armeabi-v7a 资源一致。
fun handleNativeSo() {
if (libDir.exists()) {
val supportCpuList = gameInfo.cpuSupport.split("|")
val cpuDirList = libDir.listFiles()
if (cpuDirList != null) {
for (cpuDir in cpuDirList) {
if (cpuDir.name !in supportCpuList) {
cpuDir.deleteRecursively()
}
}
}
val armeabiDir = File(libDir, "armeabi")
val armeabiV7aDir = File(libDir, "armeabi-v7a")
if (armeabiDir.exists() && armeabiV7aDir.exists()) {
armeabiV7aDir.copyRecursively(armeabiDir, true)
armeabiDir.copyRecursively(armeabiV7aDir, true)
}
}
}处理需动态申请的权限。注:收集所有需要动态申请的权限,写入 xml 中,游戏启动后进行申请。
fun handlePermission() {
val permissions = dangerousPermissions(manifestFile) // 获取清单文件中所有危险的权限
val factory = DocumentBuilderFactory.newInstance()
val newDocument = factory.newDocumentBuilder().newDocument()
val permissionsNode = newDocument.createElement("permissions")
for ((groupName, pair) in permissions) {
val permissionNode = newDocument.createElement("permission")
permissionNode.setAttribute("cname", pair.first)
permissionNode.setAttribute("name", pair.second)
permissionNode.setAttribute("group", groupName)
permissionsNode.appendChild(permissionNode)
}
newDocument.appendChild(permissionsNode)
newDocument.saveToXml(File(assetsDir, "eu_permissions.xml"))
}拷贝额外的资源到 decompile 目录下。注:便于替换资源。
fun copyExtraRes() {
val extraResDir = File(localResources.extraResourcePath)
if (extraResDir.exists()) {
File(extraResDir, "assets").copyRecursively(File(decompileDir, "assets"))
File(extraResDir, "libs").copyRecursively(File(decompileDir, "libs"))
File(extraResDir, "res").copyRecursively(File(decompileDir, "res"))
}
}拷贝渠道参数文件。注:部分渠道参数以文件形式提供。
fun copyChannelFiles() {
val channelResDir = File(localResources.channelParamFilePath)
if (channelResDir.exists()) {
channelResDir.copyRecursively(resDir)
}
}拷贝插件参数文件。注:部分插件参数以文件形式提供。
fun copyPluginFiles() {
val pluginResDir = File(localResources.pluginParamFilePath)
if (pluginResDir.exists()) {
pluginResDir.copyRecursively(resDir)
}
}合并所有资源(母包、渠道、插件)。
fun mergeAllRes() {
val resDirs = arrayListOf<File>()
resDirs.add(resDir) // 母包资源
resDirs.add(File(channelSdkTempDir, "res")) // 渠道资源
pluginSdkTempDir.listFiles()?.forEach {
resDirs.add(File(it, "res")) // 插件资源
}
mergeRes(resDirs)
}写入配置好的版本信息,minSdkVersion、targetSdkVersion。注:这个需要更新 apktool.yml 才会生效。
fun writeVersion() {
var targetSdkVersion = 26
var minSdkVersion = 15
if (channelInfo.targetSdkVersion > 0) {
targetSdkVersion = channelInfo.targetSdkVersion
}
if (channelInfo.minSdkVersion > 0) {
minSdkVersion = channelInfo.minSdkVersion
}
val ymlFile = File(decompileDir, "apktool.yml")
if (ymlFile.exists()) {
val stringBuilder = StringBuilder()
ymlFile.readLines().forEach {
when {
"minSdkVersion" in it -> {
stringBuilder.append(" minSdkVersion: '$minSdkVersion'\n") // 修改 minSdkVersion
}
"targetSdkVersion" in it -> {
stringBuilder.append(" targetSdkVersion: '$targetSdkVersion'\n")// 修改 targetSdkVersion
}
else -> {
stringBuilder.append("$it\n") // 原样输出
}
}
}
ymlFile.writeText(stringBuilder.toString()) // 更新文件
}
}写入配置信息。注:将游戏对应的 appId、appKey 等相关信息写入配置文件中,待游戏启动读取。
fun writeDevProperties() {
val targetFile = File(assetsDir, "eu_developer_config.properties")
val euServerUrl = if (gameInfo.serverBaseUrl.isBlank()) "https://api.eusdk.com" else gameInfo.serverBaseUrl
val stringBuilder = StringBuilder()
for ((paramName, paramBean) in channelParams) {
stringBuilder.append("$paramName=${paramBean.value}\n")
}
...
targetFile.writeText(stringBuilder.toString())
}将渠道和插件参数,以 meta-data 的方式写入到应用中,供 SDK 读取。
fun writeMetaData() {
val document = manifestFile.parseXml()
val appNode = document.getElementsByTagName("application").first()
val metaNodes = document.getElementsByTagName("meta-data").toNodes()
for (metaNode in metaNodes) {
val metaKey = metaNode.attr("android:name")
for (paramName in channelParams.keys) { // 渠道与母包重复,删除母包
if (paramName == metaKey) {
metaNode.removeSelf()
}
}
for (pluginParam in pluginParams) {
val params = pluginParam.params
for (paramName in params.keys) { // 插件与母包重复,删除母包
metaNode.removeSelf()
}
}
}
val keySet = mutableSetOf<String>()
for ((paramName, paramBean) in channelParams) { // 以 meta-data 方式写入渠道参数
if (!keySet.contains(paramName)) {
keySet.add(paramName)
appNode.appendChild(document.createElement("meta-data").apply {
setAttribute("android:name", paramName)
setAttribute("android:value", paramBean.value)
})
}
}
for (pluginParam in pluginParams) {
val params = pluginParam.params
for ((paramName, paramBean) in params) { // 以 meta-data 方式写入插件参数
if (!keySet.contains(paramName)) {
keySet.add(paramName)
appNode.appendChild(document.createElement("meta-data").apply {
setAttribute("android:name", paramName)
setAttribute("android:value", paramBean.value)
})
}
}
}
document.saveToXml(manifestFile)
}写入渠道配置信息。注:该文件告知融合 SDK 渠道侧哪个类实现了登录、哪个类实现了支付。
fun writePluginConfig() {
val configFile = File(channelSdkTempDir, "config.xml")
if (!configFile.exists()) {
return
}
val document = configFile.parseXml()
val pluginsNodes = document.getElementsByTagName("plugins")
if (pluginsNodes.length == 0) {
return
}
val pluginsNode = pluginsNodes.first()
val factory = DocumentBuilderFactory.newInstance()
val newDocument = factory.newDocumentBuilder().newDocument()
val newPluginsNode = newDocument.createElement("plugins")
for (childNode in pluginsNode.childNodes.toNodes()) {
newPluginsNode.appendChild(newDocument.importNode(childNode, true).apply {
attributes?.removeNamedItem("desc")
})
}
val targetFile = File(assetsDir, "eu_plugin_config.xml")
newDocument.appendChild(newPluginsNode)
newDocument.saveToXml(targetFile)
}使用 aapt2 编译所有资源,生成 R. java 文件。
fun generateRByAapt2() {
val resCompileFile = File(workspaceDir, "res.zip") // 这个其实没用,只要 R.java
val resLinkFile = File(workspaceDir, "res.apk") // 这个也没用,只要 R.java
val androidLibFile = toolsFile("android.jar")
val compileCmd = "${aapt2()} compile -o ${resCompileFile.path} --dir ${resDir.path}"
execute(compileCmd) // 执行编译
val linkCmd = "${aapt2()} link -o ${resLinkFile.path} --manifest ${manifestFile.path} -I ${androidLibFile.path} --java ${generateRDir.path} ${resCompileFile.path}"
execute(linkCmd) // 执行链接
compileR2Smali() // 将 R.java 进行编译,然后转化成 dex 再转换成 smali 文件
}
private fun compileR2Smali() {
val javaFile = File(generateRDir, "${packageName.replace('.', '/')}/R.java")
val classDir = javaFile.parentFile
val command = "javac -source 1.7 -target 1.7 -encoding UTF-8 ${javaFile.path}"
execute(command) // 编译
class2Dex(classDir, generateRDir.parentFile) // class => dex
dex2Smali(File(generateRDir.parent, "classes.dex"), smaliDir) // dex => smali
}拆分 smali 文件防止方法数超出单个 dex 上限。注:参考网址。
// 计算出的结果比实际稍微小点,实际 119769 计算 116395 不知道哪里缺了
const val PER_DEX_MAX_METHOD_COUNT = 60000
fun splitMultiDex() {
val sourceDir = File(decompileDir, "smali")
val allSmaliFiles = arrayListOf<File>()
sourceDir.walkTopDown()
.filter { it.isFile && it.extension == "smali" }
.forEach { allSmaliFiles.add(it) }
val allMethods = hashMapOf<String, MutableSet<String>>()
var currMethodCount = 0
var totalMethodCount = 0;
var smaliDirIndex = 1
for (file in allSmaliFiles) {
val methodCount = calculateMethodCount(file, allMethods)
if (currMethodCount + methodCount > PER_DEX_MAX_METHOD_COUNT) { // 超过上限换文件夹
++smaliDirIndex
currMethodCount = methodCount
} else {
currMethodCount += methodCount
}
if (smaliDirIndex > 1) {
val smaliDir = File(decompileDir, "smali_classes$smaliDirIndex")
if (!smaliDir.exists()) {
smaliDir.mkdir()
}
val targetFile = File(smaliDir, file.path.replace(sourceDir.path, "", true))
file.copyTo(targetFile)
file.delete()
}
totalMethodCount += methodCount
}
}
// 计算方法数量,allMethods 表示已经计算的方法
fun calculateMethodCount(smaliFile: File, allMethods: MutableMap<String, MutableSet<String>>): Int {
var methodCount = 0
if (!smaliFile.exists()) {
return methodCount
}
val lines = smaliFile.readLines()
val classLine = lines.first()
if (!classLine.startsWith(".class")) {
return methodCount
}
val className = classLine.split(" ").last()
for (line in lines) {
var method: String? = null
var methodClass: String = className
val newLine = line.trim()
if (newLine.startsWith(".method")) { // 定义方法,算一个
method = line.split(" ").last()
} else if (newLine.startsWith("invoke-")) { // 调用方法,算一个
val blocks = line.split("->")
method = blocks.last()
methodClass = blocks.first().split(",").last().trim()
}
if (!allMethods.containsKey(methodClass)) { // 方法重复(可能两个类调用了同一个方法)
allMethods[methodClass] = mutableSetOf()
}
val methods: MutableSet<String> = allMethods[methodClass]!!
if (method != null && method !in methods) {
methods.add(method)
methodCount++
}
}
return methodCount
}回编译 Apk。
fun recompileApk() {
val command = "${apkToolPath()} b -f ${decompileDir.path} -o ${unsignedApk.path}"
executeJava(command)
}签名 Apk。注:使用 v1 签名,由于某些应用市场不支持 v2 签名。
fun signApkByV1() {
val keyStoreFile = File(keyStore.filePath)
val aliasName = keyStore.aliasName
val aliasPassword = keyStore.aliasPwd
val password = keyStore.password
if (!keyStoreFile.exists()) {
"the keystore file is not exists".fatalLog()
}
val command = "jarsigner -digestalg SHA1 -sigalg SHA1withRSA -keystore ${keyStoreFile.path} " +
"-storepass $password -signedjar ${signedApk.path} -keypass $aliasPassword ${unsignedApk.path} $aliasName"
execute(command) {
unsignedApk.delete()
}
}对其 Apk。
fun zipAlign() {
val command = "${zipAlignPath()} -f 4 ${signedApk.path} ${alignedApk.path}"
execute(command) {
signedApk.delete()
}
}