视频链接
0 背景
我们网易前端技术部 - 移动技术组作为公司的移动端基础技术部门,主要为其他部门提供解决方案、技术支持和产品孵化。在几年的积累过程中,我们拥有一些自己的框架和 SDK
,如轻应用框架、热更新 SDK、网络请求库、本地存储库、页面管理等,服务过网易新闻、云音乐、考拉、易信等亿级产品,先后孵化过青果摄像头、二次元Gacha、严选等重要产品。
在多年的Android开发中,对于 Android 端产品开发,我们有如下几点体会:
-
产品孵化排期紧张
产品经理一般关心的是具体的业务逻辑,而前期基础模块的搭建,如各模块如何组织,使用代码结构如何选择,图片、网络、本地存储等选用哪个 sdk 等,一般不会有专门排期。
-
基础模块的需求具有相似性
内容型产品,其搭建的基础模块基本上都会包含图片显示、网络请求、本地存储、通信等。
-
基础模块的选型和工具类具有可重用性
网上相关的第三方库有很多,当然一般的公司也是会有自己开发或者维护的各个基础 SDK。很多时候,SDK 选型会更偏向于自己公司开发维护的 SDK,或者选择自己最熟悉,或最主流、最可靠的 SDK。因此当开发多个相同类型产品时,这里的技术选型是可重用的。
-
网络请求的代码具有机械性
客户端开发需要根据网络接口协议,编写相关的
GET
、POST
等请求代码和对应的JavaBean
,这部分的代码编写其实是非常机械的。
1 网易工程模板是什么?
对于各个基础模块,我们团队封装了自己的 SDK,如网络库、本地存储库、页面管理库、图片库等。使用我们的工程模板生成的初始工程,就已经包含了我们提供的基础模块,产品团队的开发不需要再花费重复的时间做技术调研、选型、SDK封装集成等工作,而只需要关心自己的业务逻辑编写。我们期望产品团队只需 1 分钟就能得到自己的初始工程,并能马上投入业务逻辑开发,既能缩短开发周期,也能保证工程代码质量。
此外,我们也提供了 Android Studio 插件 (NEIPlugin
),集成插件后,就能在 Android Studio 中通过菜单点击自动下载集成我们的工程模板,也能自动生成网络请求相关的代码。
工程模板
HTTemplate
代码生成结果示例
2 Android 模板工程实现
最初我们使用终端脚本命令的方式,通过文件拷贝和文本查找替换(主要是替换包名等)的方式实现。但终归对 Android 开发人员不太友好,毕竟大家更习惯使用 Android Studio 生成工程。所幸,强大的 Android Studio 已经提供了较为全面的模板功能,这里大概可以分为以下几类:
工程模板 (本文内容)
文件模板
注释模板
编码模板(Living Template)
2.1 Android 工程模板基础知识
2.1.1 工程模板实例介绍
对于 Android Studio
,模板位置:
Windows 的路径在 `${android studio 安装路径}/plugins/android/lib/templates/`
MacOS 的路径在 `${Android Studio.app 存放路径}/Contents/plugins/android/lib/templates/`
有关模板的文件夹:
activities
:工程模板相关,如EmptyActivity
文件夹用于创建一个空页面的模板,GoogleMapsActivity
文件夹对应创建一个地图页面的模板等gradle
:放置了gradle
模板,用于在新建工程的根目录下生成gradle
文件夹,支持用户不用安装gradle
就能使用gradlew
命令gradle-project
:工程模板相关,用于构建module
,Android Project
,Java Library
等other
:构建文件模板等
这里我们关心的是 activities
文件夹里面的内容
首先查看下 EmtpyActivity
(空白页面模板) 里面的内容
globals.xml.ftl
: 全局变量文件,保存一些全局变量,当中可以引用其他文件的全局变量recipe.xml.ftl
: 配置要引用的模板路径以及文件的生成规则-
template.xml
: 模板的配置信息,包括模板的显示图标,界面的表现,全局变量文件和执行文件的指定等 template_blank_activity.png
: 显示的缩略图SimpleActivity.java.ftl
: Activity 模板文件-
代码生成过程图
图片摘自 Tutorial How To Create Custom Android Code Templates
Android Studio 使用的是
FreeMarker
模板引擎,所以文件后缀都是.ftl
2.1.2 常用标签使用
${}
:FreeMarker
的语法,如${packageName}
,${superClass}
是globals.xml.ftl
全局变量文件或template.xml.ftl
中定义变量引用<#if>#if>
:FreeMarker
的语法,条件判断语句<#include>
:FreeMarker
的语法,包含语句copy
: 将文件或者文件夹从 from 标签拷贝到 to 标签指定的路径instantiate
: 将文件或者文件夹,执行FreeMarker
语法,从 from 标签实例化到 to 标签指定的路径merge
: 合并 from 和 to 标签分别指定的文件-
open
: 在工程打开后,默认打开指定的文件实例:使用空白页面模板生成工程并打开后,可以看到默认打开了
MainActivity.java
和activity_main.xml
文件
2.2 工程模板创建
新建 HTTemplate
文件夹内容如下:
-
template.xml
指定模板名、描述、最低支持 sdk 版本、类别等,输入界面要求指定包名和
Application
类名 -
globals.xml.ftl
引用公共文件内容
-
recipe.xml.ftl
merge
AndroidManifest.xml
文件copy 或者 merge 资源文件
copy 或 instantiate
java
代码merge
build.gradle
文件merge
settings.gradle
文件copy
lib
文件夹里面的全部内容copy
module
工程copy
proguard-rules.pro
文件
-
root 文件夹
放置相关模板源文件,其中将源工程中依赖于配置的代码,按照
FreeMarker
语法进行替换 添加工程模板图标,并在
template.xml
中添加引用
工程模板创建结果
2.3 遇到的坑与解决办法
2.3.1 build.gradle ${}
通配符冲突
当工程模板实例化时,${}
会被 FreeMarker
语法处理,导致错误。
解决办法:定义 FreeMarker
转义字符如下
$ ==> ${"$"}
2.3.2 gradle.properties.ftl
合并失败
根据错误提示,执行合并操作是只能针对 xml
或者 gradle
文件进行,其他文件并不支持合并。另外改用 copy
或 instantiate
命令也同样失败
同
proguard-rules.pro
生成失败。
解决办法:将需要定义常量的代码移动到工程根目录 build.gradle
中:定义在 ext{ }
内
2.3.3 build.gradle 合并问题
-
apply
合并失败期望结果
apply plugin: 'com.android.application' apply plugin: 'com.neenbedankt.android-apt'
实际结果
apply plugin: 'com.neenbedankt.android-apt' plugin: 'com.android.application'
dependencies
中,apt
引用代码没有出现
2.3.4 settings.gradle
文件合并问题
为了工程目录结构更清晰些,我们在 settings.gradle.ftl
文件中指定 module
的相对路径,在 recipe.xml.ftl
执行了 merge
操作。但得到错误提示:settings.gradle.ftl
中只允许 include
命令。
解决办法:将 module
工程放置在默认目录下,不再指定路径
2.3.5 Java 代码实例化问题
模板中 java
代码较多,我们统一放在 root/src/
文件夹下,里面有部分文件含有 FreeMarker
标签,有部分只是纯粹的 java 代码。而使用 instantiate
命令对整个文件夹进行实例化操作,并不会触发 FreeMarker
语法执行。
解决办法:因 java
文件比较多,手写 recipe.xml
标签命令繁琐且容易出错。我们通过程序递归遍历 root/src/
下的全部代码文件,并生成相应的 instantiate
或 copy
命令
3 工程模板遗留问题解答
工程模板相关源码位置:
Mac 平台:
${android studio 安装路径}/Contents/plugins/android/lib/android.jar
Windows 平台:
${android studio 安装路径}/plugins/android/lib/android.jar
具体类在 com/android/tools/idea/templates/
里面。
3.1 copy 和 instantiate 问题
gradle.properties
文件执行copy
或者instantiate
操作无效果原因?copy
和instantiate
对文件夹操作的区别
3.1.1 copy 命令
查看 DefaultRecipeExecutor.copy
方法,这里是直接简单的调用 copyTemplateResource
方法,该函数的基本逻辑如下:
如果 source 是一个文件夹,则执行
copyDirectory
方法,里面会递归的执行文件夹内的文件,其中如果叶子文件 (非文件夹) 对应的目标文件存在,则不执行拷贝,继续处理其他文件如果 source 非文件夹,且目标文件存在,则不执行拷贝
当上面的条件都不满足的情况下,执行文件拷贝操作
期间没有使用
FreemarkerUtils
对FreeMarker
语法进行处理
3.1.2 instantiate 命令
直接查看 DefaultRecipeExecutor.instantiate
方法,该函数的基本逻辑如下:
如果
from
文件是一个文件夹,则执行copyTemplateResource
方法,和copy
流程一样如果
from
文件非文件夹,且目标文件已经存在了,则不执行文件操作当上面的条件都不满足的情况下,先执行
FreemarkerUtils
的静态方法processFreemarkerTemplate
来处理FreeMarker
语法,之后再执行文件拷贝操作
3.1.3 遗留问题解答
-
gradle.properties
文件执行copy
或者instantiate
操作无效果原因?解答:在执行我们的工程模板执行,已经执行了
gradle-projects/NewAndroidProject
模板,并生成了gradle.properties
文件,因此执行copy
或instantiate
都因目标文件已经存在而不再执行 -
copy
和instantiate
对文件夹操作的区别解答:如果
from
指定一个文件夹,都是执行copyTemplateResource
方法,2 者没有区别
3.2 merge 问题
gradle.properties
文件执行merge
操作失败原因settings.gradle
文件合并,指定module
路径错误原因apt
语句消失原因apply
语句合并错误原因
3.2.1 merge 主流程解析
查看 DefaultRecipeExecutor.merge
方法,基本逻辑如下:
3.2.2 settings.gradle
合并
查看 RecipeMergeUtils.mergeGradleSettingsFile
方法,基本逻辑如下:
-
读取目标文件的每一行内容,并判断每行内容的开头是否是
include
开头- 是:在 include 后面插入内容
- 否:抛出异常
返回合并的内容
3.2.3 build.gradle
合并
查看 GradleFileMerger.mergeGradleFiles
方法,里面会调用 mergePsi
方法,其基本逻辑如下:
读取文件
source
和dest
文件的内容,并转化得到GroovyFile
类型对象执行
mergePsi
方法
这里 mergePsi
执行合并的逻辑是
继续查看 dependencies
合并的源码 GradleFileMerger.mergeDependencies
方法
里面的基本逻辑逻辑是:
收集 toRoot 中能解析的
compile
子元素,并将收集到的子元素从 toRoot 中删除收集 fromRoot 中的能解析的
compile
子元素,并删除能解析的compile
子元素,另外单独收集不能解析的complie
子元素遍历全部能解析的
compile
子元素,比较相同compile
语句的最大版本号,并插入到 toRoot 中遍历不能解析的
compile
子元素,将内容添加至toRoot
中
fromRoot 是我们自定义的模板文件夹中定义的
dependencies
内容
toRoot 是执行
gradle-project
中的工程模板初始创建的dependencies
内容
3.2.4 遗留问题解答
-
gradle.properties
文件执行merge
操作失败原因解答:根据
DefaultRecipeExecutor.merge
方法的逻辑,我们可以看到当to
文件不存在,则执行copy
或instantiate
命令;如果to
文件存在且可读,则仅对xml
或gradle
才能执行merge
操作 -
settings.gradle
文件合并,指定module
路径错误原因解答:只允许每行开头是
include
命令,其他情况抛出异常 -
apt
语句消失原因解答:
pullDependenciesIntoMap
方法仅处理from
文件中dependencies
中的compile
子元素,其他如apt
、provided
命令都是会被忽略掉。 -
apply
语句合并错误原因// 我们的工程模板文件内容 - 对应 mergePsi 方法中 toRoot 参数 apply plugin: 'com.neenbedankt.android-apt' // 源工程模板初始生成的 `buidl.gradle` 文件内容 - 对应 mergePsi 方法中 fromRoot 参数 apply plugin: 'com.android.application' // 期望合并结果 apply plugin: 'com.neenbedankt.android-apt' apply plugin: 'com.android.application' // 实际合并结果 apply plugin: 'com.neenbedankt.android-apt' plugin: 'com.android.application'
大概画了执行流程,里面的关键流程如下:
- 步骤 2: fromRoot 和 toRoot 不是 call 语句
- 步骤 5: 都能找到
apply
类型的子元素 - 步骤 6: 2 个
apply
的第一个子元素都不是 dependencies - 步骤 11: fromRoot 中的 apply 子元素
plugin: 'com.android.application'
和 toRoot 中的apply
子元素的plugin: 'com.neenbedankt.android-apt'
不对应 - 步骤12: 将
plugin: 'com.android.application'
添加到 toRoot 的apply
子元素前面
根据上面的分析,看起来 apply
的这个合成结果是 google
工程模板的 bug
,是不是应该提供对 apply 合并的特殊处理?
3.3 小结
到现在,我们建立了自己的工程模板。原来编码过程中碰到的问题,现在也已经从源码解析的角度做了解释。一些问题,如 gradle
文件中,dependencies
元素合并忽略自定义模板文件中的非 compile
子元素;apply
元素合并不符合我们的需求。最后导致我们不得不放弃 apt
引入。这些问题 (或者说是限制),不知 Google
方面是出于什么考虑还是本身的 bug
。
4 网络请求代码自动生成
对于 Android 工程模板安装,我们提供的插件已经实现了下载和安装功能。
其次,在当前的工程当中,我们还需要有工具,能根据 NEI 接口定义平台 中定义的网络接口,自动生成我们的网络请求相关代码 (包括各个 Request
类和 JavaBean
)。针对网络请求代码的自动生成,我们开发了 nei-toolkit,详细安装使用介绍可以查看 README.md
为了让 Android 开发人员能更加方便的使用 nei-toolkit,我们在插件中集成了 nei-toolkit
的下载、安装、使用。
4.1 插件开发基础
所有基于 IntelliJ Platform
的IDE,包括 Intellij Idea
,Android Studio
,Web Storm
等等,都可以为其添加插件以实现一些额外的功能。插件可以从本地安装,也可以从 JetBrains Plugin Repository 安装。Intellij 提供了一系列 API,使我们可以自定义插件。
-
如何配置插件开发的环境,可以查看 Setting Up a Development Environment
需要注意的是,配置 Project language level 为
Java 6
,才能支持大部分的 Android Studio 插件开发的其他基础知识,如设置按钮,如何处理事件逻辑,如何定义插件 id,名称,版本号等内容,可以查看 官方文档
4.2 执行终端命令
这里代码生成功能最终也还是执行了 nei-toolkit 中的命令来完成 http 代码生成的,因此我们使用的是 Runtime
方法来执行。
Process proc = Runtime.getRuntime().exec(command);
// 指定调用程序的工作目录
Process proc = Runtime.getRuntime().exec(cmd, null, new File(project.getBasePath()));
-
执行下载工程模板命令:
git clone ${ht-template git 地址} /Applications/Android\ Studio.app/Contents/plugins/android/lib/templates/activities/HTTemplate
MacOS 平台
-
执行代码生成命令
/usr/local/bin/node /usr/local/bin/nei mobile 11321 --lang java --appPackage com.netease.test.httemplatetest --reqAbstract com.netease.hearttouch.http.BaseRequest --baseModelAbstract com.netease.hthttp.model.BaseModel --resOut /app/src/main/hthttp-gen/ --doNotOverwrite
MacOS 平台
此外我们提供 NeiConsole
控制台,显示脚本执行输出
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
再次,除了网络请求代码编写是机械性的,其他的基于我们的工程模板生成的初始工程,也存在一定的代码编写机械性:初始页面代码生成、RecycleView
中的各个 ViewHolder
类、本地数据读取保存等,而这些工作将会是我们的后续工作。