本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。
最近在研究Android模块化开发的一些东西, 网上大多数模块化的文章都是仅仅从一个demo的角度去看待的, 其实对于在真实项目中使用还有很多坑需要去踩, 今天就来聊聊我在模块化探索过程中遇到的众多坑中的一个-多渠道.
说道多渠道, 其实大部分开发者都会在项目中使用到, 例如按照Google Play, Qihoo360… 等渠道分发, 那么我们可能这么写.
productFlavors { GooglePlay {} Qihoo360 {} }当我们需要根据不同渠道去编译不用的库文件的时候, 我们可能会这么写.
GooglePlayCompile (name: 'lib_google_play', ext: 'aar') Qihoo360Compile (name: 'lib_qihoo_360', ext: 'aar')这么写在普通的Android项目中一点毛病也没有, 那么当我们将项目模块化后呢? 这样的渠道信息还需要在每个模块中写一遍, 而且当我们增加渠道的时候还需要在每个模块中去添加渠道信息,添加渠道compile语句, 这个最终会是一个可怕的工作量. 而且, 当你的项目的build.gradle中出现大坨下面的代码的时候, 你是不是也会疯狂起来.
GooglePlayCompile (name: 'lib_google_play', ext: 'aar') Qihoo360Compile (name: 'lib_qihoo_360', ext: 'aar') ... GooglePlayCompile (name: 'biz-user_google_play', ext: 'aar') Qihoo360Compile (name: 'biz-user_qihoo_360', ext: 'aar') ...上面的类似biz-XXX的写法肯定不在少数, 因为, 当我们进行模块化开发的时候, 业务代码肯定是希望下沉的, 那样, 任何一个module都能使用. 好, 今天的重点不是讨论模块化, 关于模块化的东西我们暂且不谈.
当我看到上面的使用方式的时候, 我是不能接受的, 所以我一直在寻找合适的方式来区分渠道, 下面再来说说我在寻找这个方法过程中踩过的坑.
当我分析上面compile语句后, 我发现这些代码都是有规律的, 如果按照这个规律来讲, 这些代码都是重复的. 来看看下面两个库.
lib_google_playlib_qihoo_360其实这两个库都是对lib的封装, 不同的地方是渠道不同而已, 所以首先我想到的是要规范库文件的名称, 就像上面的lib一样, 库的名字必须是名称_渠道名的小写字母. 有了第一次突破, 下面就来思考如何简化了.
首先我想到的, 就是包装一下compile语句, 代码如下:
class Flavors { static String flavorType = "" static void onTaskEach(String task) { if(task.contains("GooglePlay")){ flavorType = "GooglePlay" } else if (task.contains("Qihoo360")){ flavorType = "Qihoo360" } } static void compile(DependencyHandler dh, String lib) { if (flavorType == null || flavorType == "") { return} dh.compile(name: "${lib}_${flavorType.toLowerCase()}", ext: 'aar') } } gradle.startParameter.getTaskNames().each { task -> if (task.startsWith(":app")) { Flavors.onTaskEach(task) } } dependencies { Flavors.compile(getDependencies(), 'biz-user') }这样的方式在我把Flavors这个类放在每个模块中的时候还是好用的, 但是这不是我的目的, 我的目的是它至少可以放到根项目的build.gradle中, 很显然, 我失败了, 原因是DependencyHandler这个玩意找不到.
既然这种方式不行, 那接下来我在yanbober提醒下, 很快的想到了第二种方式, 如果那么辅助类只给提供渠道信息呢? compile语句不做封装, Flavors提供一个current方法用来提供当前的渠道信息, 似乎这次不错, 大体代码是这样的.
// project的build.gradle ext { flavors = new Flavors() } class Flavors { static String flavorType = "" def onTaskEach(String task) { if(task.contains("GooglePlay")){ flavorType = "GooglePlay" } else if (task.contains("Qihoo360")){ flavorType = "Qihoo360" } } def current(String lib) { if (flavorType == null || flavorType == "") { return lib} return ${lib}_${flavorType.toLowerCase()} } } // module的buld.gradle gradle.startParameter.getTaskNames().each { task -> if (task.startsWith(":app")) { rootProject.ext.flavors.onTaskEach(task) } } dependencies { compile(name: rootProject.ext.flavors.current('biz-user'), ext: 'aar') }这种方式在大部分情况下是ok的, 当然我也兴奋了一小会, 但是在拿到同事电脑上用的时候, 死活就是报错, 后来我发现这种方式在我第一打开项目的时候flavorType还没拿到就进行compile了.
发现不行后, 我就忍痛删代码了. 既然这种方式走不通了, 那就退而求其次吧, 接下来我写了一个studio插件, 通过快捷键来生成分渠道的compile, 很方便, 至少还是解决了部分问题–人力问题, 但是还是不能解决’添加渠道后, 添加渠道compile语句’和代码丑这个问题. 这个插件在我们项目中现在还是保留着, 因为我们的项目中使用的网址是按照渠道分发的, 而且网络框架是Glin, 一个类似retrofit的东西, 所以网址必须是一个字面常量, 这样封装的constant必须按照按照渠道来打aar, 业务包也必须按照渠道来打包, 这一点在我分析后发现是不能逃避的, 所以这也给我定了方向, 接下来我的目标就是干掉UI层模块的多渠道.
在又经过多种方案尝试后, 我有了一个疯狂的想法–自己模拟实现一下多渠道, 而且最终也被我实现了(要不然也不会有本文了…), 最后总结了一下, 很多时候我们的思路都被现有的大家都这么用的方式给束缚住了, 我们需要大胆的想象, 必要的时候可以打破这种束缚.
要自己去模拟多渠道, 那么需要解决的问题如下,
实现全量渠道包的生成.实现单个渠道包的生成.实现渠道的切换.实现manifestPlaceholders功能第一点是我们要实现的基础功能. 第一点如果能实现, 第二点就不是问题了. 第三点是为了直接run的时候能统一渠道而必须要实现的, 想一下, 如果测试同事拿着手机和数据线来找你跑一个最新版的时候, 你总不能打出所有包来再给人家安装吧. 第四点的需求没有那么强烈, 但是也有可能会用到, 所以我放到最后去实现的.
先来说说我的思路吧, 使用命令gradlew assembleDebug可以打出一个包来, 那么我就用一个循环来打包, 打一次包, 将compile给替换一下, 继续打包, 直到我们的渠道遍历完为止, 当然了, 这个替换过程是全自动的.
那如果替换compile呢? 我的做法是在compile之前给我一个特定的标识, 所以我们的compile长这样.
/**rep*/compile(name:'biz-user_google_play', ext: 'aar')这样, 在打包的时候, 我发现/**rep*/标识后的compile会进行动态替换.
接下来, 我们先来看看新的实现是如果使用的.
gradlew publish
用来实现上面问题1.
gradlew publishDefault
用来实现上面问题2, 但是它会依赖我们一个配置值.
gradlew chVar
用来实现上面问题3
上面3个命令对应了3个gradle task.
所以先来创建3个task, 在看代码之前, 我们先来看看配置文件.
// publish_config.gradle ext { defaultVariantIndex = 0 variants = ['GooglePlay','GooglePlayTest','Qihoo360','Qihoo360Test'] apps = ['demo'] }这里的配置是需要手动修改的, 可以看到渠道信息是定义了一个variants列表.
3个task的代码如下,
// publish.gradle ext { gradles = '/build.gradle' rep = "/**rep*/" aarPat = ".*compile\\s*\\(name:\\s*[\"']\\s*([a-zA-Z0-9-]+?)_.*\\).*" supportReplacePat= "\\/\\*\\*support:\\s*(.*)\\s*\\*\\/" } tasks.create(name: 'publish') << { for (variant in variants) { start variant } println "All finished, restore compile to default" repCompile variants[defaultVariantIndex] } tasks.create(name: 'publishDefault') << { start variants[defaultVariantIndex] } tasks.create(name: 'chVar') << { repCompile variants[defaultVariantIndex] }来看看publish这个task的代码, 首先我们来遍历所有的variant, 然后调用start函数, 最后执行完毕后, 还要将所有的build.gradle还原一下.
再来看看start这个函数,
def start(String variant) { repCompile variant def gradlewCmd = isWindows() ? "./gradlew.bat" : "gradlew" def proc = Runtime.getRuntime().exec "${gradlewCmd} assembleDebug" new StreamPrinter(proc.getInputStream(), "INFO").start() new StreamPrinter(proc.getErrorStream(), "ERROR").start() proc.waitFor() rename "-debug", "_${variant}" println "--------SUCCESS--------" }首先调用了repCompile这个函数, 这个函数的作用肯定就是替换我们上面提到的语句了, 接下来, 执行了gradlew assembleDebug来打包, 打包完毕后还要重命名一下apk文件, 这样也是传统的渠道打包具有的功能, 这里rename函数实现了重命名的功能.
再来看看repCompile这个函数,
def repCompile(String v) { for (app in apps) { def path = "./${app}/" def current = file(path) current.eachFile { file -> if (file.isDirectory()) { repFile file.path + gradles, v } } } }这里遍历了一下所有的app, 然后拿到app下的文件进行遍历, 在遍历过程中又调用了repFile这个函数, 这个函数才是真正执行替换的地方.
def repFile(String f, String variant) { def file = file(f) def txt = file.text def lines = txt.readLines() def size = lines.size() for (def i = 0; i < size; i++) { def line = lines[i] def rep = repAAR line, variant if (rep != "") { txt = txt.replace line, rep } } file.write txt }这里首先是按行读取了每个module下的build.gradle文件, 然后调用repAAR函数, 这个函数的作用判断当前行是不是需要做替换, 如果需要会返回替换后的新内容, 接下来, 如果需要替换, 替换当前行, 最终再将新内容重新写入文件.
aar的compile的动态替换是这么替换的.
def repAAR(String line, String variant) { if (!line.trim().startsWith(rep)) { return "" } def pattern = java.util.regex.Pattern.compile aarPat def matcher = pattern.matcher line if (matcher.find()) { def libName = matcher.group 1 return " /**rep*/compile (name:'${libName}_${variant.toLowerCase()}', ext:'aar')" } return "" }这里首先判断当前行的内容是否配置aarPat这个正则, 如果匹配, 则根据当前的lib名称和当前遍历到的渠道名生成一个compile语句的字符串, 然后返回, 至于aarPat, 可以翻翻上文找到. 到这里, 我们就实现了compile语句的替换, 接下来就是重命名了.
def rename(String oldSuffix, String newSuffix) { def roots = project.getSubprojects() for (item in roots) { if (!projectInApps(item.getName())) { continue} def children = item.getSubprojects() if (children.isEmpty()) { continue} for (module in children) { def lineList = module.getBuildFile().readLines() if (lineList.get(0).contains("com.android.application")) { def oldName = module.getName() + oldSuffix def newName = module.getName() + newSuffix def path = module.getBuildDir().getPath() def apk = file("${path}/outputs/apk/${oldName}.apk") def renameApk = file("${path}/outputs/apk/${newName}.apk") if (apk.exists()) { if (renameApk.exists()) { renameApk.delete() } println "rename ${apk.getName()} to ${renameApk.getName()}.apk" apk.renameTo renameApk } } } } }这里的代码有点多, 具体的思路就是拿到所有的module, 然后再拿到该moudle下的build.gradle文件, 读取它的第一行, 看看是不是apply plugin: 'com.android.application',毕竟只有application的module才会有apk生成, 最后判断它的build下是不是有apk-debug.apk文件, 如果有, 则进行重命名.
这样, 整个过程就完成了, publishDefault和chVar命令的过程, 也在这个过程中完成了, publishDefault就是拿出defaultVariantIndex对应的variant然后调用start函数, chVar就是拿出defaultVariantIndex对应的variant然后调用repCompile函数.
还有最后一个问题还有说到, 就是manifestPlaceholders的实现, 下面就来实现下这个功能. 参考compile的实现思路, manifestPlaceholders的实现也是替换的思路, 所以在使用的时候用这样的方式,
buildTypes { debug { /**support: GooglePlay,GooglePlayTest*/ manifestPlaceholders = [VALUE: 'google'] /**support: Qihoo360,Qihoo360Test*/ //manifestPlaceholders = [VALUE: '360'] } }对于它的处理思路是, 在repFile函数按行读的时候, 判断该行是不是/**support:渠道1,渠道2...*/这样的方式, 如果匹配成功, 接着判断当前渠道是否在place的渠道列表中, 如果在, 则将下面一行的注释//删除掉, 如果不是, 在下面一行添加注释//, 具体的实现代码,
def repFile(String f, String variant) { def file = file(f) def txt = file.text def lines = txt.readLines() def size = lines.size() for (def i = 0; i < size; i++) { def line = lines[i] // 省略上面repAAR的代码 def matched = matchPlace(line, variant) if (matched != 0 && i + 1 < size) { def nextLine = lines[i + 1] if (matched == 1) { if (!nextLine.trim().startsWith("//")) { txt = txt.replace nextLine, "//" + nextLine } } else if (matched == 2) { if (nextLine.trim().startsWith("//")) { txt = txt.replace nextLine, nextLine.replaceFirst("//", "") } } } } file.write txt }看for循环里的代码, 首先调用matchPlace函数, 该函数具有int类型的返回值, 如果是0, 则表示不匹配, 如果是1, 则表示匹配成功但是当前渠道不再place渠道列表中, 如果是2, 则表示匹配成功并且当前渠道在place渠道列表中. 接下来就是对返回值的判断, 根据返回值matched的值来决定下一行是添加注释还是删除注释.
再来看看matchPlace函数的实现,
/** return 0 no matched, * 1 matched but not contains current variant, * 2 matched and contains current variant */ def matchPlace(String line, String variant) { def pattern = java.util.regex.Pattern.compile supportReplacePat def matcher = pattern.matcher line if (matcher.find()) { def vs = matcher.group 1 def vsArray = vs.split "," if (inArray(variant, vsArray)) { return 2 } return 1 } return 0 }这里的代码, 就是实现了上面的关于返回值定义的思路, 利用正则来匹配当前行, 根据匹配结果和渠道信息来决定返回结果.
到现在为止, 已经实现了对多渠道的基本功能的模拟, 而且, 也成功的干掉了UI模块的多渠道信息, 再也不用书写那么多渠道信息了, 模块的build.gradle里也清爽了, 再也不用手撕那么多XXXCompile了. 最后给出完整的代码, 如果需要, 根据项目结构稍作修改就可以使用了.
// publish_config.gradle ext { defaultVariantIndex = 0 variants = ['GooglePlay','GooglePlayTest','Qihoo360','Qihoo360Test'] apps = ['demo'] } // publish.gradle /** * gradlew publish \n * gradlew publishDefault \n * gradlew chVar \n */ apply from: './publish_config.gradle' ext { gradles = '/build.gradle' rep = "/**rep*/" aarPat = ".*compile\\s*\\(name:\\s*[\"']\\s*([a-zA-Z0-9-]+?)_.*\\).*" supportReplacePat= "\\/\\*\\*support:\\s*(.*)\\s*\\*\\/" } tasks.create(name: 'publish') << { for (variant in variants) { start variant } println "All finished, restore compile to default" repCompile variants[defaultVariantIndex] } tasks.create(name: 'publishDefault') << { start variants[defaultVariantIndex] } tasks.create(name: 'chVar') << { repCompile variants[defaultVariantIndex] } def start(String variant) { repCompile variant def gradlewCmd = isWindows() ? "./gradlew.bat" : "gradlew" def proc = Runtime.getRuntime().exec "${gradlewCmd} assembleDebug" new StreamPrinter(proc.getInputStream(), "INFO").start() new StreamPrinter(proc.getErrorStream(), "ERROR").start() proc.waitFor() rename "-debug", "_${variant}" println "--------SUCCESS--------" } def repCompile(String v) { for (app in apps) { def path = "./${app}/" def current = file(path) current.eachFile { file -> if (file.isDirectory()) { repFile file.path + gradles, v } } } } def repFile(String f, String variant) { def file = file(f) def txt = file.text def lines = txt.readLines() def size = lines.size() for (def i = 0; i < size; i++) { def line = lines[i] def rep = repAAR line, variant if (rep != "") { txt = txt.replace line, rep } def matched = matchPlace(line, variant) if (matched != 0 && i + 1 < size) { def nextLine = lines[i + 1] if (matched == 1) { if (!nextLine.trim().startsWith("//")) { txt = txt.replace nextLine, "//" + nextLine } } else if (matched == 2) { if (nextLine.trim().startsWith("//")) { txt = txt.replace nextLine, nextLine.replaceFirst("//", "") } } } } file.write txt } /** return 0 no matched, * 1 matched but not contains current variant, * 2 matched and contains current variant*/ def matchPlace(String line, String variant) { def pattern = java.util.regex.Pattern.compile supportReplacePat def matcher = pattern.matcher line if (matcher.find()) { def vs = matcher.group 1 def vsArray = vs.split "," if (inArray(variant, vsArray)) { return 2 } return 1 } return 0 } def repAAR(String line, String variant) { if (!line.trim().startsWith(rep)) { return "" } def pattern = java.util.regex.Pattern.compile aarPat def matcher = pattern.matcher line if (matcher.find()) { def libName = matcher.group 1 return " /**rep*/compile (name:'${libName}_${variant.toLowerCase()}', ext:'aar')" } return "" } def rename(String oldSuffix, String newSuffix) { def roots = project.getSubprojects() for (item in roots) { if (!projectInApps(item.getName())) { continue} def children = item.getSubprojects() if (children.isEmpty()) { continue} for (module in children) { def lineList = module.getBuildFile().readLines() if (lineList.get(0).contains("com.android.application")) { def oldName = module.getName() + oldSuffix def newName = module.getName() + newSuffix def path = module.getBuildDir().getPath() def apk = file("${path}/outputs/apk/${oldName}.apk") def renameApk = file("${path}/outputs/apk/${newName}.apk") if (apk.exists()) { if (renameApk.exists()) { renameApk.delete() } println "rename ${apk.getName()} to ${renameApk.getName()}.apk" apk.renameTo renameApk } } } } } def projectInApps(String name) { for (app in apps) { if (name.equals(app)) { return true} } return false } def isWindows() { def osName = System.getProperty("os.name").toLowerCase() def result = osName.startsWith "windows" return result } def inArray(String str, String[] array) { for (item in array) { if (item.trim().equals(str)) { return true} } return false } public class StreamPrinter extends Thread { private InputStream inputStream private String type StreamPrinter(InputStream inputStream, String type) { this.inputStream = inputStream this.type = type } public void run() { BufferedReader br try { InputStreamReader inputStreamReader = new InputStreamReader(inputStream) br = new BufferedReader(inputStreamReader) String line = null while ((line = br.readLine()) != null) { println type + " > " + line if (line.toLowerCase().contains("build failed")) { println "exit..." System.exit 1 } } } catch (IOException e) { e.printStackTrace() } finally { if (br != null) { try { br.close() } catch (Exception e2) { e2.printStackTrace() } } } } }最后别忘了在项目的build.gradle中添加一下代码,
apply from: './publish.gradle'