使用 Gradle 实现一套代码开发多个应用

danny5258 7年前
   <p>在文章 <a href="/misc/goto?guid=4959750417915021508" rel="nofollow,noindex">使用 Gradle 对应用进行个性化定制</a> 中,我们能够针对一个应用的正式服、测试服、超管服等其他版本,进行个性化定制。</p>    <p>这一篇文章我们来点大动作,让你用一套代码构建多个应用。</p>    <h2>场景介绍</h2>    <p>需求:“将某个应用换一套皮肤、第三方账号、后台服务器,改个名字上线,并且以后的新功能同步进行更新”。</p>    <p>当你遇到这样的需求会怎么做呢?</p>    <p>是将项目复制一份,然后修改其中的内容,有新功能的时候再手动复制过来稍微修改一下 UI?</p>    <p>或者可以切换一个分支,在这个分支上修改相关的信息,每次开发完新功能,将代码合并过来,再稍微修改新功能的 UI?</p>    <p>现在我来介绍使用 Gradle 的 flavorDimensions ,实现一份代码构建多个应用。</p>    <h2>具体实现</h2>    <p>老规矩,先上完整的 Gradle 配置:</p>    <pre>  <code class="language-groovy">android {      compileSdkVersion 25      buildToolsVersion "25.0.3"      defaultConfig {          minSdkVersion 16          targetSdkVersion 25          versionCode gitVersionCode()      }        // 配置两个应用的签名文件      signingConfigs {          app1 {              storeFile file("app1.jks")              storePassword "111111"              keyAlias "app1"              keyPassword "111111"          }            app2 {              storeFile file("app2.jks")              storePassword "111111"              keyAlias "app2"              keyPassword "111111"          }      }        buildTypes {          release {              // 不显示Log              buildConfigField "boolean", "LOG_DEBUG", "false"          }            debug {              // 显示Log              buildConfigField "boolean", "LOG_DEBUG", "true"              versionNameSuffix "-debug"              signingConfig null              manifestPlaceholders.UMENG_CHANNEL_VALUE = "test"          }      }        //创建两个维度的 flavor      flavorDimensions "APP", "SERVER"        productFlavors {            app1 {              dimension "APP"              applicationId 'com.imliujun.app1'                versionName rootProject.ext.APP1_versionName                //应用名              resValue "string", "app_name", "APP1"                buildConfigField("String", "versionNumber", "\"${rootProject.ext.APP1_versionName}\"")                //第三方SDK的一些配置              buildConfigField "int", "IM_APPID", "app1的腾讯IM APPID"              buildConfigField "String", "IM_ACCOUNTTYPE", "\"app1的腾讯IM accountype\""              manifestPlaceholders = [UMENG_APP_KEY      : "app1的友盟 APP KEY",                                      UMENG_CHANNEL_VALUE: "app1默认的渠道名",                                      XG_ACCESS_ID       : "app1信鸽推送ACCESS_ID",                                      XG_ACCESS_KEY      : "app1信鸽推送ACCESS_KEY",                                      QQ_APP_ID          : "app1的QQ_APP_ID",                                      AMAP_KEY           : "app1的高德地图key",                                      APPLICATIONID      : applicationId]              //签名文件              signingConfig signingConfigs.app1          }            app2 {              dimension "APP"              applicationId 'com.imliujun.app2'                versionName rootProject.ext.APP2_versionName                //应用名              resValue "string", "app_name", "APP2"                buildConfigField "String", "versionNumber", "\"${rootProject.ext.APP2_versionName}\""                //第三方SDK的一些配置              buildConfigField "int", "IM_APPID", "app2的腾讯IM APPID"              buildConfigField "String", "IM_ACCOUNTTYPE", "\"app2的腾讯IM accountype\""              manifestPlaceholders = [UMENG_APP_KEY      : "app2的友盟 APP KEY",                                      UMENG_CHANNEL_VALUE: "app2默认的渠道名",                                      XG_ACCESS_ID       : "app2信鸽推送ACCESS_ID",                                      XG_ACCESS_KEY      : "app2信鸽推送ACCESS_KEY",                                      QQ_APP_ID          : "app2的QQ_APP_ID",                                      AMAP_KEY           : "app2的高德地图key",                                      APPLICATIONID      : applicationId]              //签名文件              signingConfig signingConfigs.app2          }            offline {              dimension "SERVER"                versionName getTestVersionName()          }            online {              dimension "SERVER"          }            admin {              dimension "SERVER"                versionName rootProject.ext.versionName + "-管理员"              manifestPlaceholders.UMENG_CHANNEL_VALUE = "admin"          }      }  }    android.applicationVariants.all { variant ->      switch (variant.flavorName) {          case "app1Admin":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://admin.app1domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getTestVersionName() + "-管理员")              } else {                  variant.mergedFlavor.setVersionName(rootProject.ext.APP1_VERSION_NAME + "-管理员")              }              break          case "app1Offline":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://offline.app1domain.com/\""              variant.mergedFlavor.setVersionName(getTestVersionName())              break          case "app1Online":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://online.app1domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getTestVersionName())              }              break          case "app2Admin":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://admin.app2domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getApp2TestVersionName() + "-管理员")              } else {                  variant.mergedFlavor.setVersionName(rootProject.ext.APP2_VERSION_NAME + "-管理员")              }              break          case "app2Offline":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://offline.app2domain.com/\""              variant.mergedFlavor.setVersionName(getApp2TestVersionName())              break          case "app2Online":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://online.app2domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getApp2TestVersionName())              }              break      }  }</code></pre>    <pre>  <code class="language-groovy">ext {      APP1_VERSION_NAME = "2.0.2"      APP1_TEST_NUM = "0001"      APP2_VERSION_NAME = "1.0.5"      APP2_TEST_NUM = "0005"  }    def getTestVersionName() {      return String.format("%s.%s", rootProject.ext.APP1_VERSION_NAME,              rootProject.ext.APP1_TEST_NUM)  }    def getApp2TestVersionName() {      return String.format("%s.%s", rootProject.ext.APP2_VERSION_NAME,              rootProject.ext.APP2_TEST_NUM)  }    static int gitVersionCode() {      def count = "git rev-list HEAD --count".execute().text.trim()      return count.isInteger() ? count.toInteger() : 0  }</code></pre>    <p>在上一篇文章的配置上进行了一些修改,同时保留上一篇文章里所有的功能。</p>    <h2>配置多应用</h2>    <p>首先来看最重要的一个概念:</p>    <pre>  <code class="language-groovy">flavorDimensions "APP", "SERVER"</code></pre>    <p>这一行代码配置了两个维度的 flavor , APP 代表多应用, SERVER 代表服务器版本。</p>    <p>根据上面的配置信息可以看到, app1 、 app2 设置了 dimension "APP" 所以属于 APP 这个维度, offline 、 online 、 admin 设置了 dimension "SERVER" 属于 SERVER 这个维度。</p>    <p>根据 Product Flavors 的两个维度 APP [app1, app2] 和 SERVER [offline, online, admin] 以及 Build Type [debug, release],最后会生成以下 Build Variant:</p>    <ul>     <li>app1AdminDebug</li>     <li>app1AdminRelease</li>     <li>app1OfflineDebug</li>     <li>app1OfflineRelease</li>     <li>app1OnlineDebug</li>     <li>app1OnlineRelease</li>     <li>app2AdminDebug</li>     <li>app2AdminRelease</li>     <li>app2OfflineDebug</li>     <li>app2OfflineRelease</li>     <li>app2OnlineDebug</li>     <li>app2OnlineRelease</li>    </ul>    <p>是不是每个应用都有 3 个服务器版本,每个版本都有 debug 和 release 包。</p>    <h2>动态配置 URL 和版本号</h2>    <p>既然每个 Build Variant 都是由不同维度的 Product Flavors 和 Build Type 组合而来,我们肯定不能像上一篇文章一样将服务器的 URL 配置在 offline 、 online 、 admin 中了,因为 app1Offline 和 app2Offline 同样是测试服,但不是同一个应用 URL 也不一样。</p>    <p>这个时候就需要通过 task 操作来根据不同的组合设置不同的数据了。</p>    <pre>  <code class="language-groovy">android.applicationVariants.all { variant ->      //判断当前的 flavorName 是什么版本      switch (variant.flavorName) {          case "app1Admin":              //这是 app1 的超管版本,设置超管服务器 URL              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://admin.app1domain.com/\""              //判断当前是 `debug` 包还是 `release` 包,设置版本号              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getTestVersionName() + "-管理员")              } else {                  variant.mergedFlavor.setVersionName(rootProject.ext.APP1_VERSION_NAME + "-管理员")              }              break          case "app1Offline":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://offline.app1domain.com/\""              variant.mergedFlavor.setVersionName(getTestVersionName())              break          case "app1Online":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://online.app1domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getTestVersionName())              }              break          case "app2Admin":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://admin.app2domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getApp2TestVersionName() + "-管理员")              } else {                  variant.mergedFlavor.setVersionName(rootProject.ext.APP2_VERSION_NAME + "-管理员")              }              break          case "app2Offline":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://offline.app2domain.com/\""              variant.mergedFlavor.setVersionName(getApp2TestVersionName())              break          case "app2Online":              variant.buildConfigField "String", "DOMAIN_NAME",                      "\"https://online.app2domain.com/\""              if ("debug" == variant.buildType.getName()) {                  variant.mergedFlavor.setVersionName(getApp2TestVersionName())              }              break      }  }</code></pre>    <p>两个 APP 的服务器 URL 和版本号不一致,所以通过 task 来动态设置。</p>    <h2>配置应用名</h2>    <p>不同的应用配置自己的应用名:</p>    <pre>  <code class="language-groovy">resValue "string", "app_name", "APP1"</code></pre>    <p>这行代码的意思和在 strings.xml 中定义一个 String 值是一样的。不过这里通过 Gradle 配置了 app_name 就不能在 strings.xml 中再定义了,会报错提示有冲突。</p>    <h2>配置应用签名</h2>    <p>如果多个应用使用同一个签名文件,按照上一篇文章写的在 buildTypes 的 release 和 debug 中配置就可以。但是每个应用的签名文件不一样呢?</p>    <pre>  <code class="language-groovy">signingConfigs {        app1 {          storeFile file("app1.jks")          storePassword "111111"          keyAlias "app1"          keyPassword "111111"      }        app2 {          storeFile file("app2.jks")          storePassword "111111"          keyAlias "app2"          keyPassword "111111"      }  }</code></pre>    <p>配置多个签名文件,在 APP 这个维度的 flavor 中配置签名信息:</p>    <pre>  <code class="language-groovy">app1{      signingConfig signingConfigs.app1  }    app2{      signingConfig signingConfigs.app2  }</code></pre>    <p>这样就可以针对不同的应用设置不同的签名文件了。 但是,还有一个要注意的地方,这个坑我以前没填上,而是绕远路绕过去了,现在我来填上它!</p>    <pre>  <code class="language-groovy">debug {      signingConfig null  }</code></pre>    <p>一定要在 debug 中将签名文件的配置置空,不然 Build Type 的权限比 Product Flavors 要高,而 debug Build Type(构建类型) 会自动使用 debug SigningConfig (签名配置),这样一来就将 flavor 中配置的签名信息给覆盖掉了。导致的问题就是编译 release 包没有问题,编译 debug 包就不能使用某些需要校验签名的第三方SDK了。</p>    <h2>配置不同应用的代码和资源</h2>    <p>终于来到重头戏了,现在只需要更换 UI、文案或者某些界面布局和逻辑代码就大功告成啦。</p>    <p>首先,建立每个应用对应的 sourceSets 目录,比如:</p>    <ul>     <li>app1 的 sourceSets 位置是 src/app1/</li>     <li>app2 的 sourceSets 位置是 src/app2/</li>    </ul>    <p>app1 是已经开发完成的应用,只需要换 UI、文案就成了 app2 ,在 src/app2/ 目录下再新建 res 目录,将需要替换的切图命名和 app1 中的命名保持一致放入 res 对应的目录下就完美换肤了。</p>    <p>文案同理,将需要替换的字符串在 src/app2/res/values/strings.xml 中再写一份,保持 name 相同,其中的内容随便替换。</p>    <p>布局文件、style、color 替换的规则同上。</p>    <p>微信登录、分享、支付的回调是返回到 {应用包名.wxapi.WXEntryActivity} 、 {应用包名.wxapi.WXPayEntryActivity} 这两个 Activity。</p>    <p>我们在 app1 和 app2 中都放入这两个回调 Activity:</p>    <p><img src="https://simg.open-open.com/show/c864bf89eb7b6eba04d83e86cfc6c620.png"></p>    <p>sourceSets 文件目录</p>    <p>然后在 AndroidManifest.xml 文件中动态配置 Activity 的包名:</p>    <pre>  <code class="language-groovy"><!-- 微信分享回调 -->  <activity android:name="${APPLICATIONID}.wxapi.WXEntryActivity"/>  <!-- 微信支付的回调 -->  <activity android:name="${APPLICATIONID}.wxapi.WXPayEntryActivity"/></code></pre>    <p>APPLICATIONID 占位符在 Gradle 中设置:</p>    <pre>  <code class="language-groovy">manifestPlaceholders = [APPLICATIONID : applicationId]</code></pre>    <p>如果使用了 ShareSDK 做第三方分享和登录,需要配置 ShareSDK.xml 放到 assets 文件夹下,将 main/assets/ShareSDK.xml 复制一份到 app2/assets/ShareSDK.xml ,将里面的第三方 APP ID 和 APP KEY 替换一下就可以了。</p>    <p>项目如果使用了 ContentProvider 要注意替换 authorities ,可以和上面动态配置 Activity 包名一样操作,用信鸽 SDK 演示一下:</p>    <pre>  <code class="language-groovy"><!-- 【必须】 【注意】authorities修改为 包名.AUTH_XGPUSH, 如demo的包名为:com.qq.xgdemo -->  <provider      android:name="com.tencent.android.tpush.XGPushProvider"      android:authorities="${APPLICATIONID}.AUTH_XGPUSH"      android:exported="true"/></code></pre>    <h2>总结</h2>    <p>上面的内容基本涉及到所有的方面,其他的细节也好,特殊的需求定制也好,使用上面的方式去处理都能够解决。希望大家不要光学会复制粘贴,要掌握其原理,遇到类似的需求就能举一反三。</p>    <p>总结一下技术点:</p>    <ul>     <li>manifestPlaceholders -> AndroidManifest.xml 占位符</li>     <li>buildConfigField -> BuildConfig 动态配置常量值</li>     <li>resValue -> String.xml 动态配置字符串</li>     <li>signingConfigs -> 配置签名文件</li>     <li>productFlavors -> 产品定制多版本</li>     <li>flavorDimensions -> 为产品定制设置多个维度</li>     <li>android.applicationVariants -> 操作 task</li>    </ul>    <p> </p>    <p>来自:https://juejin.im/post/59648441f265da6c415f3078</p>    <p> </p>