桌面打包工具

前言

目前在线打包系统虽然能够满足渠道分包需求,但是还存在很多问题,比如操作复杂度高、游戏渠道关联和渠道信息配置入口不统一、母包需要上传打包机器打完后还要下载(包体积上 G)、多个用户公用一个打包机器,基于上述原因开发了一款桌面打包工具。

技术选型

由于我们开发者使用的是 Mac 电脑,而用户(运营或者游戏提供商)使用的是 Windows 电脑,因此两个平台都需要兼顾到,最初考虑是 Windows 版本使用 Java Swing 进行开发,Mac 版本使用原生进行开发,但是这样势必要写两份代码,那么有没有一份代码就可以同时在两个平台上运行的方法呢?通过查阅资料发现 Electron 可以满足这个要求,其使用前端技术(Vue、React)进行开发,一份代码可以同时编译出 dmg 以及 exe,同时 VisualStudioCode 也是使用该技术进行开发的,最终就选定使用该方案,由前端同事负责页面绘制,我负责逻辑处理。

界面展示

大体上主要分为登录、游戏配置、渠道配置、打包四个界面。

  1. 登录界面

  2. 游戏配置界面

  3. 渠道配置界面

  4. 打包界面

基本原理

整体架构图如下:

截屏2021-03-09 上午11.04.42

整体时序图如下:

时序图

打包原理

JS 层主要完成资源准备,比如下载闪屏图、渠道插件 SDK 资源等,以及结果处理,然后调用 Kotlin 进行打包,在打包过程中会适时的写文件更新进度,JS 只需开启一个定时器一直读取进度即可,等到命令执行完成,判断下进度是否为 100,即可判断打包是否成功。这里只关注下打包流程。

注:原先采用 Python2 编写,我改为使用 Kotlin 编写,因为我感觉 Python 执行速度没有 Kotlin 快,同时 Kotlin 语法糖也多,不像 Java 那么慢。

打包流程如下(参考 U8 SDK):

  1. 反编译母包。

    /**
    * 反编译 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)
    }
  2. 合并 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")
    }
    }
  3. 删除测试渠道资源,为了便于 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)
    }
  4. 重命名包名。

    注:根据配置将 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)
    }
  5. 重命名应用名。

    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)
    }
  6. 处理渠道和插件 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,所以需要解析进行合并。

    • 使用 d8sdkTemp/libs 以及 sdkTemp/下所有的 jar 包编译成 classes.dex 文件。注:可能还会有 classes2.dex 等。

    • 使用 baksmali 将上一步生成的 dex 文件编译成 smali 文件夹,并将其拷贝到 decompile/smali 目录下。

    • sdkTemp/SDKManifest.xmldecompile/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 的合并流程,这个在文章最后进行总结。

  7. 处理占位符信息。注:比如微信回调类名固定要是 包名.WXEntryActivity,就可以使用该占位符。

    fun handlePlaceHolders() {
    modifyFileContent(manifestFile, "${'$'}{applicationId}", packageName)
    }
    fun modifyFileContent(file: File, oldValue: String, newValue: String) {
    file.writeText(file.readText().replace(oldValue, newValue)) // 简单的全部读出来,替换后写进去
    }
  8. 处理 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)
    }
    }
    }
  9. 处理需动态申请的权限。注:收集所有需要动态申请的权限,写入 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"))
    }
  10. 拷贝额外的资源到 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"))
    }
    }
  11. 拷贝渠道参数文件。注:部分渠道参数以文件形式提供。

    fun copyChannelFiles() {
    val channelResDir = File(localResources.channelParamFilePath)
    if (channelResDir.exists()) {
    channelResDir.copyRecursively(resDir)
    }
    }
  12. 拷贝插件参数文件。注:部分插件参数以文件形式提供。

    fun copyPluginFiles() {
    val pluginResDir = File(localResources.pluginParamFilePath)
    if (pluginResDir.exists()) {
    pluginResDir.copyRecursively(resDir)
    }
    }
  13. 合并所有资源(母包、渠道、插件)。

    fun mergeAllRes() {
    val resDirs = arrayListOf<File>()
    resDirs.add(resDir) // 母包资源
    resDirs.add(File(channelSdkTempDir, "res")) // 渠道资源
    pluginSdkTempDir.listFiles()?.forEach {
    resDirs.add(File(it, "res")) // 插件资源
    }
    mergeRes(resDirs)
    }
  14. 写入配置好的版本信息,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()) // 更新文件
    }
    }
  15. 写入配置信息。注:将游戏对应的 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())
    }
  16. 将渠道和插件参数,以 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)
    }
  17. 写入渠道配置信息。注:该文件告知融合 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)
    }
  18. 使用 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
    }
  19. 拆分 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
    }
  20. 回编译 Apk。

    fun recompileApk() {
    val command = "${apkToolPath()} b -f ${decompileDir.path} -o ${unsignedApk.path}"
    executeJava(command)
    }
  21. 签名 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()
    }
    }
  22. 对其 Apk。

    fun zipAlign() {
    val command = "${zipAlignPath()} -f 4 ${signedApk.path} ${alignedApk.path}"
    execute(command) {
    signedApk.delete()
    }
    }
0%