本文由网易杭州前端技术部首发。
背景
我们网易前端技术部 - 移动技术组作为公司的移动端基础技术部门,主要为其他部门提供解决方案、技术支持和产品孵化。在几年的积累过程中,我们拥有一些自己的框架和 SDK,如轻应用框架、热更新 SDK、网络请求库、本地存储库、页面管理等,服务过网易新闻、云音乐、考拉、易信等亿级产品,先后孵化过青果摄像头、二次元Gacha、严选等重要产品。
在多年的Android开发中,对于 Android 端产品开发,我们有如下几点体会:
产品孵化排期紧张
基础模块的需求具有相似性
基础模块的选型和工具类具有可重用性
网络请求的代码具有机械性
对于各个基础模块,我们团队封装了自己的 SDK,如网络库、本地存储库、页面管理库、图片库等。使用我们的Activity模板生成的初始工程,就已经包含了我们提供的基础模块,产品团队的开发不需要再花费重复的时间做技术调研、选型、SDK封装集成等工作,而只需要关心自己的业务逻辑编写。我们期望产品团队只需 1 分钟就能得到自己的初始工程,并能马上投入业务逻辑开发,既能缩短开发周期,也能保证工程代码质量。
Android 模板简介
Android Studio 提供遵循 Android 设计和开发最佳实践的代码模板,以帮助我们快速并正确地创造出漂亮的、功能齐全的应用程序代码模板。Android Studio 中提供的模板列表在不断增加,按照它们添加的组件类型(如Activity或XML文件)可对模板进行如下分组:
可通过文件->新建菜单或在项目窗口中右键单击调出上述模板菜单。
Android Studio 模板位置:
Windows 的路径在 ${android studio 安装路径}/plugins/android/lib/templates/
MacOS 的路径在 ${Android Studio.app 存放路径}/Contents/plugins/android/lib/templates/
该文件夹具体内容如下:
activities:Activity模板相关,如 EmptyActivity 文件夹用于创建一个空页面的模板,GoogleMapsActivity 文件夹对应创建一个地图页面的模板等
gradle:放置了 gradle 模板,用于在新建工程的根目录下生成 gradle 文件夹,支持用户不用安装 gradle 就能使用 gradlew 命令
gradle-project:工程模板相关,用于构建 module,Android Project,Java Library 等
other:构建文件模板等
模板最常见的用途之一是向现有应用程序模块添加新的 Activity。 activities 文件夹正是 Android Studio 默认提供的 Activity 模板,涵盖了手机和平板电脑应用中常用的 Activiy 模板。用户也可以参照已有模板自定义符合特定需求的 Activity 模板。
1. Activity 模板的文件结构
下面我们分析最简单的一个模板 EmptyActivity,我们首先查看下 EmtpyActivity (空白页面模板) 里面的内容
Android Studio 使用的是 FreeMarker 模板引擎,所以文件后缀都是 .ftl
globals.xml.ftl: 全局变量文件,保存一些全局变量,当中可以引用其他文件的全局变量
recipe.xml.ftl: 配置要引用的模板路径以及文件的生成规则
template.xml: 模板的配置信息,包括模板的显示图标,界面的表现,全局变量文件和执行文件的指定等
template_blank_activity.png: 显示的缩略图
SimpleActivity.java.ftl: Activity 模板文件
2. 代码生成流程
目前我们已经基本了解了一个Activity模板的文件结构了,以及每个文件大致包含的东西,简单总结如下:
template 中parameter标签,主要用于提供参数
global.xml.ftl 主要用于提供参数
-
recipe.xml.ftl 主要用于生成我们实际需要的代码,资源文件等
例如,利用参数 + MainActivity.java.ftl -> MainActivity.java,其实就是利用参数将ftl中的变量进行替换
代码生成过程如下图所示:
HTTemplate Activity 模板实现
我们编写一个Activity模板叫作:HTTemplate,内容如下:
1. template.xml
指定模板名、描述、最低支持 sdk 版本、类别等,输入界面要求指定包名和 Application 类名
2. globals.xml.ftl
引用公共文件内容
<?xml version="1.0"?>
<globals>
<global id="hasNoActionBar" type="boolean" value="false" />
<#include "../common/common_globals.xml.ftl" />
</globals>
3. recipe.xml.ftl
<?xml version="1.0"?>
<recipe>
<!-- nei.json -->
<instantiate from="root/nei.json.ftl"
to="${escapeXmlAttribute(topOut)}/nei.json" />
<!-- manifest -->
<merge from="root/AndroidManifest.xml.ftl"
to="${escapeXmlAttribute(manifestOut)}/AndroidManifest.xml" />
<merge from="root/AndroidManifestPermissions.xml"
to="${escapeXmlAttribute(manifestOut)}/AndroidManifest.xml" />
<!-- 全部资源 -->
<copy from="root/res"
to="${escapeXmlAttribute(resOut)}" />
<!-- libs 库 -->
<copy from="root/libs"
to="${escapeXmlAttribute(projectOut)}/libs" />
<!-- gradle -->
<merge from="root/project_build.gradle.ftl"
to="${escapeXmlAttribute(topOut)}/build.gradle" />
<merge from="root/app_build.gradle.ftl"
to="${escapeXmlAttribute(projectOut)}/build.gradle" />
<!-- README -->
<instantiate from="root/README.md.ftl"
to="${escapeXmlAttribute(topOut)}/README.md" />
<!-- proguard-rules.pro.ftl -->
<copy from="root/proguard-rules.pro.ftl"
to="${escapeXmlAttribute(projectOut)}/proguard-rules.pro.template" />
<!-- java 代码 -->
<!-- application 文件夹 -->
<instantiate from="root/src/app_package/application/AppProfile.java.ftl"
to="${escapeXmlAttribute(srcOut)}/application/AppProfile.java" />
...
<!-- attrs.xml -->
<merge from="root/res/values/attrs.xml"
to="${escapeXmlAttribute(resOut)}/values/attrs.xml" />
...
</recipe>
省略部分代码,主要的工作是
merge AndroidManifest.xml 文件
copy 或者 merge 资源文件
copy 或 instantiate java 代码
merge build.gradle 文件
merge settings.gradle 文件
copy lib 文件夹里面的全部内容
copy proguard-rules.pro 文件
4. root 文件夹
放置相关模板源文件,包括一些Activity、自定义View、通用工具类等。将其中以.ftl
为后缀的源代码,按照 FreeMarker 语法进行替换。例如,使用了包名的地方,需要替换成 ${packageName}:
package ${packageName}.application;
import ${packageName}.R;
在gradle文件中配置私有maven库的地址、增加公用的依赖库、关闭lint的严格检查、配置APK多渠道打包等。
5. 模板图标
添加 Activity 模板图标,并在 template.xml 中添加引用
<thumbs>
<thumb>template_thumb.png</thumb>
</thumbs>
6. 使用 Activity 模板生成初始工程
将上述 HTTemplate 文件夹拷贝至 Android Studio 的 Activity 模板目录下:
Windows 的路径在
${android studio 安装路径}/plugins/android/lib/templates/activities
MacOS 的路径在
${Android Studio.app 存放路径}/Contents/plugins/android/lib/templates/activities
重启 Android Studio,在新建工程过程中可以看到,出现了我们自定义的 Activity 模板项:
直接运行生成的项目,效果如下:
到这里,使用我们的 Activity 模板生成的初始工程,就已经包含了我们提供的网络库、本地存储库、页面管理库、图片库等基础模块,开发人员接下来只需专注于业务逻辑的开发。
遇到的问题及解决方案
为分析问题的原因,我们找到了 Android 模板相关源码:
Mac 平台:
${android studio安装路径}/Contents/plugins/android/lib/android.jar
Windows 平台:
${android studio安装路径}/plugins/android/lib/android.jar
以下问题的解答将涉及到部分源码。
1. ${} 通配符冲突
当工程模板实例化时,${} 会被 FreeMarker 语法处理,导致错误。
解决办法:定义 FreeMarker 转义字符如下
$ ==> ${"$"}
2. Java 代码实例化问题
模板中 java 代码较多,我们统一放在 root/src/ 文件夹下,里面有部分文件含有 FreeMarker 标签,有部分只是纯粹的 java 代码。而使用 instantiate 命令对整个文件夹进行实例化操作,并不会触发 FreeMarker 语法执行。
解决办法:因 java 文件比较多,手写 recipe.xml 标签命令繁琐且容易出错。我们通过程序递归遍历 root/src/ 下的全部代码文件,并生成相应的 instantiate 或 copy 命令。
3. copy 和 instantiate 问题
(1) gradle.properties 文件执行 copy 或者 instantiate 操作无效
分析结果:查看 DefaultRecipeExecutor.copy 与 DefaultRecipeExecutor.instantiate 源码处理逻辑,得知执行 copy 和 instantiate 命令时,如果 from 指定一个非文件夹,且目标文件存在,则不执行拷贝。而在执行我们的 Activity 模板之前,已经执行了 gradle-projects/NewAndroidProject 工程模板,并生成了 gradle.properties 文件,因此执行 copy 或 instantiate 都因目标文件已经存在而不再执行。
(2) copy 和 instantiate 对文件夹操作的区别
相同点:如果 from 指定一个文件夹,都是执行 copyTemplateResource 方法,二者没有区别;如果 from 指定一个非文件夹,且目标文件存在,则不执行文件操作。
不同点:copy 命令不使用 FreemarkerUtils 对 FreeMarker 语法进行处理,而 instantiate 命令先执行 FreemarkerUtils 的静态方法 processFreemarkerTemplate 来处理 FreeMarker 语法,之后再执行文件拷贝操作。
4. merge 问题
(1) proguard-rules.pro、gradle.properties 文件执行 merge 操作失败
分析结果:根据 DefaultRecipeExecutor.merge 源码的逻辑,我们得知当 to 文件不存在,则执行 copy 或 instantiate 命令;如果 to 文件存在且可读,则仅对 xml 或 gradle 才能执行 merge 操作。
解决办法:
暂时生成 proguard-rules.pro.template 文件
将定义在 gradle.properties 中的常量移动到 project_build.gradle.ftl 的 ext{ } 内
(2) settings.gradle 文件合并,指定 module 路径错误
执行前:
include ':hteventbus', ':htrefreshrecyclerview', ':htrecycleview', ':hthttp'
project(':hteventbus').projectDir = new File('module/hteventbus')
project(':hthttp').projectDir = new File('module/hthttp')
project(':htrefreshrecyclerview').projectDir = new File('module/htrefreshrecyclerview')
project(':htrecycleview').projectDir = new File('module/htrecycleview')
执行后报错:
RuntimeException: java.lang.RuntimeException: When merging settings.gradle files, only include directives can be merged.
分析结果:查看 RecipeMergeUtils.mergeGradleSettingsFile 源码,得知当 settings.gradle 文件合并时,只允许每行开头是 include 命令,其他情况抛出异常。
解决办法:去掉非 include 的操作代码,改用远程依赖引用这些 module,即在 dependencies{ } 中添加相应的依赖。
(3) build.gradle 文件合并,apply 语句合并错误
执行前:
apply plugin: 'com.neenbedankt.android-apt'
执行后:
apply plugin: 'com.neenbedankt.android-apt' plugin: 'com.android.application'
分析结果:查看 GradleFileMerger 中的 mergeGradleFiles 方法,实际执行的是 mergePsi 方法,根据 mergePsi 合并逻辑,apply 不是 call 语句,且 apply 的第一个子元素不是 dependencies,因此添加 plugin: 'com.neenbedankt.android-apt' 到 toRoot 的 apply 子元素前面。
解决办法:根据上面的分析,看起来 apply 的这个合成结果是 Android 模板的 bug,我们目前只能采用手工添加 apply 语句的方法。
(4) build.gradle 文件合并,dependencies{ } 内的 apt 语句消失
执行前:
dependencies {
compile "com.netease.hearttouch:ht-universalrouter-dispatch:$HEARTTOUCH_HTROUTER_DISPATCH_VERSION"
apt "com.netease.hearttouch:ht-universalrouter-dispatch-process:$HEARTTOUCH_HTROUTER_DISPATCH_PROCESS_VERSION"
...
}
执行后:
dependencies {
compile "com.netease.hearttouch:ht-universalrouter-dispatch:$HEARTTOUCH_HTROUTER_DISPATCH_VERSION"
...
}
分析结果:查看 GradleFileMerger.mergeDependencies 源码,得知当 dependencies 合并时,仅处理 dependencies 中的 compile 子元素,其他如 apt、provided 命令都会被忽略掉。
解决办法:由于源码并未提供非 compile 子元素的合并方案,我们目前只能采用手工添加 apt 语句的方法。
5. 小结和后续工作
到此,基本上完成了我们原先期望实现的初始工程:
提供 ht-template 支持生成我们的模板工程
提供 Android Studio 插件 (NEIPlugin)
支持 ht-template 的下载安装
nei-toolkit 和 Node.js 的下载安装
nei-toolkit 和 Node.js 的使用,生成网络请求代码
这里还是有一些因为 Android 模板自身的限制而无法完成的内容点:
无法在 settings.gradle 指定 module 路径
无法合并 proguard-rules.pro 文件,暂时生成 proguard-rules.pro.template 文件
由于 build.gradle 对 apply 命令合并会出错和无法合并 dependencies 中的 apt 命令,所以无法在 build.gradle 中集成 ht-universalrouter
再次,除了网络请求的代码编写是机械性的,基于我们的 Activity 模板生成的初始工程,在其他方面也存在代码编写的机械性:初始页面代码生成、RecycleView 中的各个 ViewHolder 类、本地数据读取保存等,而这些工作将会是我们的后续工作。