Fork me on GitHub

Android Studio模板之文件组

文件组模板是基于FreeMarker模板语言的一个功能很强大的Android开发模板,可以这样说,代码片段模板和文件模板是一种提高编码效率的工具,而文件组模板可以算是一种模板引擎。

效果图展示

一图胜千言,先上图

step5

已有工程中使用模板效果图

filesTemplate1.gif

创建工程时使用模板

filesTemplate2

示例场景

在进行Android开发时,我们经常会创建一个Demo工程,目的可能有很多种,可能是为了验证一个问题,可能是为了学习一个框架的使用,可能为了测试自己写的一个lib库等等。这个时候我们可能会创建一个Activity,然后再在xml写一些按钮,再在Activity里写该按钮的事件监听逻辑,也就是说为了执行一段代码我们要做这么多操作。为了简化这段重复操作,我这边写了一个DebugActivity类,然后支持我们只需要写个子类来继承它,然后像下面这样写几个方法即可,运行的时候会根据方法动态创建按钮,并在点击按钮时执行该方法的代码逻辑。

1
2
3
public void _test() {
T("弹出Toast");
}

由于本文主要介绍模板相关的,所以该场景相关的具体代码技术细节就不多说了,有兴趣的可以看下,DebugActivity的代码,这里提出来只是为模板开发简单的做个铺垫。

模板位置

Android Studio Template中有系统预设的一些模板,我们可以直接修改,也可以另行添加新的模板。打开Android Studio安装目录/Contents/plugins/android/lib/templates这个文件夹我们能看到下面的目录结构,这里便是AS中模板存放的位置。

step1

我们接下来的工作也就在这里,保险起见我们在这里新建一个目录,我们自己写的模板都放在自己新建的目录里,例如我这里就创建了一个叫pk的目录。

模板规范

在上面的基础上,我们可以直接打开/activies/EmptyActivity目录,如下图

step2

我们可以看到上面红色区域便是Template的文件结构,大致说下各个文件(夹)的含义

  • globals.xml.ftl 模板中参数配置的地方(可选)
  • recipe.xml.ftl 模板行为执行处,引入这个模板之后,接下来要做什么事情,就是它说的算(可选,但是不选就没有意义了,因为模板引入是要要行为驱动的)
  • root 存放模板文件及引入资源的目录,模板文件可以是.xml.java.gradle等任何一个文本格式的文件,资源一般是我们引入的.png资源文件(可选,不选同上)
  • template_blank_activity.png 引入模板时的引导图(可选)
  • template.xml 面向模板引擎的配置文件(必选)

我们可以看到,真正核心的部分就是rootrecipe.xml.ftltemplate.xml,接下来这重点说明这三部分。

我们可以打开root目录,能够看到里面的文件除了图片资源文件都是以.ftl结尾的,而.ftl是标准的FreeMarker的文件。FreeMarker是类似于Velocity的一种模板框架,据说对于多文件处理时它具有更好的性能,大概也是Android Studio选择Velocity作为单文件模板,选择FreeMarker作为文件组模板的原因吧。有兴趣的可以去FreeMarker官网学习一下,它的自定义标签功能还是很强大的,个人感觉比Velocity的更加接地气。

接下来我们看一下recipe.xml.ftl 的内容,打开如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0"?>
<recipe>
<#include "../common/recipe_manifest.xml.ftl" />

<#if generateLayout>
<#include "../common/recipe_simple.xml.ftl" />
<open file="${escapeXmlAttribute(resOut)}/layout/${layoutName}.xml" />
</#if>

<instantiate from="root/src/app_package/SimpleActivity.java.ftl"
to="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />

<open file="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />
</recipe>

这里以<#开头的都是FreeMarker的语法,基本上比葫芦画瓢就能看明白,就不多说了。其实对于这个文件最重要的部分是下面四个标签

  • copy 就是简单的copy,把模板root目录下的某个文件copy到目标工程的某个目录下
  • instantiate 跟copy很类似,唯一多的一点功能就是并不只简单的走IO流进行copy,而是通过FreeMarker框架按照模板中的FreeMarker能识别的逻辑判断和数据引入来生成最终的目标文件
  • merge 目标项目中有了某文件,而我们还要想该文件合并一些我们的模板的部分时,就选用merge,例如我们添加一个Activity时需要mergeAndroidManifest.xml的配置。目前支持的merge格式有.xml.gradle,但是对.gradle支持的不怎么好,不过不影响该模板的开发,对于这套模板引擎的开发者来说,这可能是最麻烦的部分了,但是对于我们使用者就不用考那么多了,直接使用吧
  • open 这个很简单,就是指定模板引入之后要IDE打开的文件

然后看下template.xml内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?xml version="1.0"?>
<template
format="5"
revision="5"
name="Empty Activity"
minApi="7"
minBuildApi="14"
description="Creates a new empty activity">

<category value="Activity" />
<formfactor value="Mobile" />

<parameter
id="activityClass"
name="Activity Name"
type="string"
constraints="class|unique|nonempty"
suggest="${layoutToActivity(layoutName)}"
default="MainActivity"
help="The name of the activity class to create" />

<parameter
id="generateLayout"
name="Generate Layout File"
type="boolean"
default="true"
help="If true, a layout file will be generated" />

<parameter
id="layoutName"
name="Layout Name"
type="string"
constraints="layout|unique|nonempty"
suggest="${activityToLayout(activityClass)}"
default="activity_main"
visibility="generateLayout"
help="The name of the layout to create for the activity" />

<parameter
id="isLauncher"
name="Launcher Activity"
type="boolean"
default="false"
help="If true, this activity will have a CATEGORY_LAUNCHER intent filter, making it visible in the launcher" />

<parameter
id="packageName"
name="Package name"
type="string"
constraints="package"
default="com.mycompany.myapp" />

<!-- 128x128 thumbnails relative to template.xml -->
<thumbs>
<!-- default thumbnail is required -->
<thumb>template_blank_activity.png</thumb>
</thumbs>

<globals file="globals.xml.ftl" />
<execute file="recipe.xml.ftl" />

</template>

当我们进行模板引入时,AS会弹出一个如下图的UI界面,要我们来填入或选择一些数据,例如输入Activity的的名称,选择SDK的版本之类的。而这个界面就是根据由该文件而来的。

step3

内容比较多,为减少篇幅我挑些重要的说

  • template标签
    • name 引入模板时的模板名称,就死根据他选择哪个模板的
    • description 弹出Dialog的标题,对应上去的区域1
  • category 表示该模板属于哪种分类,在引入的时候会有个分类的选择
  • parameter 每个该标签就对应Dialog界面的一个输入项
    • id 该参数的唯一标识符,也是我们在.ftl中引入的值,例如定义的id为username,引用时就是$username
    • name 对应Dialog上面该输入项的名称
    • type 对应该参数的类型,Dialog就是根据这个来决定对应输入是选择框、输入框还是下拉框等等
    • constraints 对应该参数的约束,如果有多个要用|分割开
    • suggest 建议值,这个输入部分是由级联效应的,可能你改了A参数,B参数也会跟着改变,就是根据这个参数决定的
    • default 参数的默认值
    • visibility 可见性,要配置一个boolean类型的参数,一般指向另一个输入源
    • help 当焦点在某个输入源上面时,上图的区域3的就限制这儿的内容

操刀实战

了解了模板规范之后,我们编写模板时就不会那么被动了,下面我们来自己动手编写文章开始部分展示的模板。

首先在刚才提到的自定义的模板下创建如下图所示的目录结构

  • DebugActivity
    • root
      • src
        • app_package
          • DebugActivity.java.ftl
          • JumpActivity.java.ftl
          • SimpleActivity.java.ftl
      • AndroidManifest.xml.ftl
    • globals.xml.ftl
    • recipe.xml.ftl
    • template.xml
    • template_debug_activity.png

然后将下面的代码对应贴进去(图片部分随便找一张代替好了…)

globals.xml.ftl

1
2
3
4
5
6
<?xml version="1.0"?>
<globals>
<global id="resOut" value="${resDir}" />
<global id="srcOut" value="${srcDir}/${slashedPackageName(packageName)}" />
<global id="relativePackage" value="<#if relativePackage?has_content>${relativePackage}<#else>${packageName}</#if>" />
</globals>

recipe.xml.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0"?>
<recipe>

<instantiate from="root/src/app_package/DebugActivity.java.ftl"
to="${escapeXmlAttribute(srcOut)}/DebugActivity.java" />

<instantiate from="root/src/app_package/SimpleActivity.java.ftl"
to="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />

<#if addJumpActivity>
<instantiate from="root/src/app_package/JumpActivity.java.ftl"
to="${escapeXmlAttribute(srcOut)}/JumpActivity.java" />
</#if>

<merge from="root/AndroidManifest.xml.ftl"
to="${escapeXmlAttribute(manifestOut)}/AndroidManifest.xml" />

<open file="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />
<open file="${escapeXmlAttribute(srcOut)}/DebugActivity.java" />

</recipe>

template.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?xml version="1.0"?>
<template
format="5"
revision="5"
name="Debug Activity"
minApi="7"
minBuildApi="14"
description="创建一个Debug的Activity">

<category value="Activity" />
<formfactor value="Mobile" />

<parameter
id="activityClass"
name="Activity名称"
type="string"
constraints="class|unique|nonempty"
default="SetupActivity"
help="创建Activity的名称" />

<parameter
id="addExample"
name="是否添加按钮使用示例"
type="boolean"
default="false"
help="选择时会自动生成测试按钮;否则不生成" />

<parameter
id="addJumpActivity"
name="是否添加跳转Activity示例"
type="boolean"
default="false"
help="选择时会自动生成跳转Activity相关逻辑;否则不生成" />


<parameter
id="isLauncher"
name="设为启动页面"
type="boolean"
default="true"
help="选择时设置该页面为启动页面;否则不设" />

<parameter
id="packageName"
name="包名"
type="string"
constraints="package"
default="com.mycompany.myapp"
help="输入Application包名" />

<!-- 128x128 thumbnails relative to template.xml -->
<thumbs>
<!-- default thumbnail is required -->
<thumb>template_debug_activity.png</thumb>
</thumbs>

<globals file="globals.xml.ftl" />
<execute file="recipe.xml.ftl" />

</template>

AndroidManifest.xml.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<#if addJumpActivity>
<activity android:name="${relativePackage}.JumpActivity"/>
</#if>

<activity android:name="${relativePackage}.SetupActivity">
<#if !(isLibraryProject!false) && isLauncher>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</#if>
</activity>
</application>

</manifest>

DebugActivity.java.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package ${packageName};

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.Toast;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

/**
* Debug测试类,快速调试Demo工程<hr />
* 使用姿势:<br />
* 1. 新建一个子类继承该类<br />
* 2. 跳转Activity: 在子类配置{@link Jump}注解, 然后在注解中配置跳转Activity的类型<br />
* 3. 点击按钮触发方法: 在子类声明一个名称以"_"开头的方法(支持任意修饰符),最终生成按钮的文字便是改方法截去"_"<br />
* 4. 方法参数支持缺省参数和单个参数<br />
* 5. 如果是单个参数,参数类型必须是Button或Button的父类类型,当方法执行时,该参数会被赋值为该Buttom对象<br />
* https://github.com/puke3615/DebugActivity<br />
* <p>
*
* @author zijiao
* @version 16/10/16
*/
public abstract class DebugActivity extends Activity {

protected static final String FIXED_PREFIX = "_";
private final String TAG = getClass().getName();
private final List<ButtonItem> buttonItems = new ArrayList<>();
protected LinearLayout linearLayout;
protected Context context;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Jump {
Class<? extends Activity>[] value() default {};
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.context = this;
ScrollView scrollView = new ScrollView(this);
setContentView(scrollView);
this.linearLayout = new LinearLayout(this);
this.linearLayout.setOrientation(LinearLayout.VERTICAL);
scrollView.addView(linearLayout);
try {
resolveConfig();
createButton();
} catch (Throwable e) {
error(e.getMessage());
}
}

private void createButton() {
for (ButtonItem buttonItem : buttonItems) {
linearLayout.addView(buildButton(buttonItem));
}
}

protected View buildButton(final ButtonItem buttonItem) {
final Button button = new Button(this);
button.setText(buttonItem.name);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (buttonItem.target != null) {
to(buttonItem.target);
} else {
Method method = buttonItem.method;
method.setAccessible(true);
Class<?>[] parameterTypes = method.getParameterTypes();
int paramSize = parameterTypes.length;
switch (paramSize) {
case 0:
try {
method.invoke(DebugActivity.this);
} catch (Throwable e) {
e.printStackTrace();
error(e.getMessage());
}
break;
case 1:
if (parameterTypes[0].isAssignableFrom(Button.class)) {
try {
method.invoke(DebugActivity.this, button);
} catch (Throwable e) {
e.printStackTrace();
error(e.getMessage());
}
break;
}
default:
error(method.getName() + "方法参数配置错误.");
break;
}
}
}
});
return button;
}

private void resolveConfig() {
Class<?> cls = getClass();
//读取跳转配置
if (cls.isAnnotationPresent(Jump.class)) {
Jump annotation = cls.getAnnotation(Jump.class);
for (Class<? extends Activity> activityClass : annotation.value()) {
buttonItems.add(buildJumpActivityItem(activityClass));
}
}
//读取方法
for (Method method : cls.getDeclaredMethods()) {
handleMethod(method);
}
}

protected void handleMethod(Method method) {
String methodName = method.getName();
if (methodName.startsWith(FIXED_PREFIX)) {
methodName = methodName.replaceFirst(FIXED_PREFIX, "");
ButtonItem buttonItem = new ButtonItem();
buttonItem.method = method;
buttonItem.name = methodName;
buttonItems.add(buttonItem);
}
}

protected ButtonItem buildJumpActivityItem(Class<? extends Activity> activityClass) {
ButtonItem buttonItem = new ButtonItem();
buttonItem.name = "跳转到" + activityClass.getSimpleName();
buttonItem.target = activityClass;
return buttonItem;
}

public void L(Object s) {
Log.i(TAG, s + "");
}

public void error(String errorMessage) {
T("[错误信息]\n" + errorMessage);
}

public void T(Object message) {
Toast.makeText(context, String.valueOf(message), Toast.LENGTH_SHORT).show();
}

public void to(Class<? extends Activity> target) {
try {
startActivity(new Intent(this, target));
} catch (Exception e) {
e.printStackTrace();
error(e.getMessage());
}
}

public void T(String format, Object... values) {
T(String.format(format, values));
}

protected static class ButtonItem {
public String name;
public Method method;
public Class<? extends Activity> target;
}

}

JumpActivity.java.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package ${packageName};

import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;

/**
* @author zijiao
* @version 16/10/16
*/
public class JumpActivity extends Activity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView text = new TextView(this);
text.setText("跳转Activity成功");
setContentView(text);
}
}

SimpleActivity.java.ftl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package ${packageName};

@DebugActivity.Jump({
<#if addJumpActivity>
JumpActivity.class,
<#else>

</#if>
})
public class ${activityClass} extends DebugActivity {

<#if addExample>
private int number = 0;

public void _无参方法调用() {
T("无参方法调用");
}

public void _有参方法调用(Button button) {
button.setText("number is " + number++);
}

//代码执行不到,直接弹出toast提示报错
public void _错误参数调用(String msg) {
T("test");
}

//方法名没有以"_"开头,按钮无法创建成功
public void 无效调用() {
T("test");
}

//crash会被会被catch住,以toast方式弹出
public void _Crash测试() {
int a = 1 / 0;
}

</#if>

}

ok,到此对于该模板的编写过程就结束了,接下来重启下Android Studio,然后New Project一路next下去,直到这个界面,这里就是我们自定义的DebugActivity模板了

step4

下面是该模板的Github源码

https://github.com/puke3615/DebugActivity.git


------------- The end -------------