gradleでlayout.xmlからクラス生成
layout.xml の id から View クラスを引いてくるのは結構面倒くさいので自動生成する方法を考えてみました。
アノテーションでやる方法は有りますが1つの項目にたいして2行の宣言が必要です。
通常は layout.xml に id を指定した時点で命名規則に従っているのでこの2行も必要無いはずです。
やりたいことはこんな感じです。
レイアウト定義:
res/layout/activity_main.xml:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<ListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</FrameLayout>
res/layout/list_item.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_edit"/>
<TextView
android:id="@+id/label"
android:layout_gravity="left|center_vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sample"
android:textSize="16dp"/>
</LinearLayout>
生成コード
フィールド名は id をキャメル変換してプレフィックスを付けています。
generated/layout/src/.../layout/ActivityMainViews.java:
package org.kotemaru.android.myapp.layout;
import ...
public class ActivityMainViews {
public final ListView mListView;
public ActivityMainViews(android.app.Activity root) {
this.mListView = (ListView) root.findViewById(R.id.list_view);
}
}
generated/layout/src/.../layout/ListItemViews.java:
package org.kotemaru.android.myapp.layout;
import ...
public class ListItemViews {
public final ImageView mIcon;
public final TextView mLabel;
public ListItemViews(View root) {
this.mIcon = (ImageView) root.findViewById(R.id.icon);
this.mLabel = (TextView) root.findViewById(R.id.label);
}
}
アプリ実装
MainActivity.java:
package org.kotemaru.android.myapp;
import org.kotemaru.android.myapp.layout.ActivityMainViews;
import org.kotemaru.android.myapp.layout.ListItemViews;
public class MainActivity extends AppCompatActivity {
private ActivityMainViews mViews;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mViews = new ActivityMainViews(this);
mViews.mListView.setAdapter(new PkgListAdapter());
}
class PkgListAdapter extends BaseAdapter {
private PackageManager mPackageManager = getPackageManager();
private LayoutInflater mInflater = getLayoutInflater();
private List<ApplicationInfo> mItemInfos;
…中略…
@Override
public View getView(final int position, View view, ViewGroup parent) {
if (view == null) {
view = mInflater.inflate(R.layout.list_item, null, false);
view.setTag(new ListItemViews(view));
}
ListItemViews views = (ListItemViews) view.getTag();
ApplicationInfo info = mItemInfos.get(position);
views.mIcon.setImageDrawable(info.loadIcon(mPackageManager));
views.mLabel.setText(info.loadLabel(mPackageManager));
return view;
}
}
}
プラグインの実装
gradle のプラグインで実装します。
app/build.gradle:
build.gradle でプラグインを適用するだけです。
apply plugin: 'com.android.application'
apply from: './generateLayoutClass.gradle' // <-- insert this line
android {
:
app/generateLayoutClass.gradle:
プラグインの本体はこれファイルだけです。
- res/layout/*.xml を読み込んで generated/layout/src/ に Javaクラスを出力します。
- srcDirs に generated/layout/src/ を追加しています。
apply plugin: GenerateLayoutClassPlugin
tasks.preBuild.dependsOn 'generateLayoutClass';
class GenerateLayoutClassPluginExtension {
def final String generatedSrcDir = "generated/layout/src/main/java/";
def String appPackage = null;
def String layoutSubPackage = ".layout";
def String classSuffix = "Views";
def String fieldPrefix = "m";
}
class GenerateLayoutClassPlugin implements Plugin<Project> {
GenerateLayoutClassPluginExtension extension;
void apply(Project project) {
project.extensions.create('generateLayoutClass', GenerateLayoutClassPluginExtension);
extension = project.generateLayoutClass;
File generatedSrcDir = new File(project.buildDir, extension.generatedSrcDir);
project.android {
sourceSets {
main {
java {
srcDirs += generatedSrcDir;
}
}
}
}
project.task('generateLayoutClass') << {
if (extension.appPackage == null) extension.appPackage = project.android.defaultConfig.applicationId;
FileTree tree = project.fileTree(dir: 'src/main/res/layout');
tree.include('*.xml');
tree.each { File file ->
generateLayoutClass(file, generatedSrcDir);
}
}
}
String snake2camel(String snake, boolean isFirstUpper) {
StringBuilder sbuf = new StringBuilder(snake.length());
String[] words = snake.split('_');
for (String word : words) {
sbuf.append(Character.toUpperCase(word.charAt(0)));
sbuf.append(word.substring(1));
}
if (!isFirstUpper) {
sbuf.setCharAt(0, Character.toLowerCase(sbuf.charAt(0)));
}
return sbuf.toString();
}
class View {
String name, id, fieldName, baseName;
def View(node) {
this.name = node.name();
this.id = node.'@android:id'.toString().replaceFirst(/^.*\//, "");
this.baseName = snake2camel(this.id, (extension.fieldPrefix != ""));
this.fieldName = extension.fieldPrefix + this.baseName;
}
}
void generateLayoutClass(File xmlFile, File outDir) {
String appPackage = extension.appPackage;
String layoutPackage = appPackage + extension.layoutSubPackage;
def views = [];
def layout = new XmlSlurper().parse(xmlFile).declareNamespace(
android: 'http://schemas.android.com/apk/res/android',
tools: 'http://schemas.android.com/tools'
);
def isActivity = (layout.'@tools:context' != "");
layout.'**'.each { node ->
if (node.'@android:id' != "") {
views.add(new View(node));
}
}
int start = xmlFile.absolutePath.lastIndexOf(File.separator);
int end = xmlFile.absolutePath.lastIndexOf('.');
String className = snake2camel(xmlFile.absolutePath.substring(start + 1, end), true) + extension.classSuffix;
File parentDir = new File(outDir, layoutPackage.replace('.', '/'));
parentDir.mkdirs();
File outFile = new File(parentDir, className + ".java");
if (outFile.exists() && outFile.lastModified() > xmlFile.lastModified()) {
return;
}
FileWriter out = new FileWriter(outFile);
out.println("package ${layoutPackage};");
out.println("import ${appPackage}.R;");
out.println("import android.view.*;");
out.println("import android.widget.*;");
out.println("public class ${className} {");
for (def view : views) {
out.println(" public final ${view.name} ${view.fieldName};");
}
if (isActivity) {
out.println(" public ${className}(android.app.Activity root) {");
} else {
out.println(" public ${className}(View root) {");
}
for (def view : views) {
out.println(" this.${view.fieldName} = (${view.name}) root.findViewById(R.id.${view.id});");
}
out.println(" }");
out.println("}");
out.close();
}
}
実行結果
ビルドするとちゃんとクラスが生成されています。
まとめ
gradleでのコード生成は手軽な上に拡張性もあるので今後重宝しそうです。
Velocity と組み合わせができるともっとパワフルに使えるかもしれません。
AndroidStudioのビルド高速化
Android Studio の gradle は柔軟で良いのですが大規模プロジェクトだとビルドが遅くて萎ます。
開発途中のビルドを少しでも早くする為にサブモジュールになっている 自前ライブラリのコンパイルを一時的に止めて外部参照する方法を考えました。
build.gradle を色々いじってみたのですが結局 settings.gradle で include してしまうと dependencies から外してもサブモジュールのビルドが走ってしまうようです。
結果として以下の手順でうまくいきました。
- gradle.properties に切り替え用プロパティを追加する。
- settings.gradle で上のプロパティを参照して include を切り替える。
- app/build.gradle で上のプロパティを参照して dependencies のライブラリの参照先を .aar or .jar に切り替える。
.aar や .jar は一度フルコンパイルすればビルドされたものが残っているので repositories の flatDir で指定すると外部ライブラリの扱いで参照できます。
以下は
- mylibrary に Androidライブラリ・モジュール
- libtest に Javaライブラリ・モジュール
が存在するケースの例です。
gradle.properties:
compileMode=single or multi
settings.gradle:
println("compileMode="+compileMode)
if (compileMode == "single") {
println("include ':app'")
include ':app'
} else {
println("include ':app',':mylibrary',':libtest'")
include ':app',':mylibrary',':libtest'
}
app/build.gradle:
…省略…
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:23.1.0'
if (compileMode == "single") {
releaseCompile(name: 'mylibrary-release', ext: 'aar')
debugCompile(name: 'mylibrary-debug', ext: 'aar')
compile(name: 'libtest', ext: 'jar')
} else {
compile project(':mylibrary')
compile project(':libtest')
}
}
repositories {
flatDir {
dirs = [
'../mylibrary/build/outputs/aar',
'../libtest/build/libs'
]
}
}
まだ、実務環境に適用できていないですがこれで早くなって欲しいな~
追記:
実務に適用してみましたが数秒しか効果がありませんでした。orz
Android Studio でコンパイルの前処理
Android Studio の gradle でコンパイル前に処理を入れる方法を調べたメモ。
- 環境:Android studio 1.3
build.gradle の拡張については意外に情報が少ないです。
公式の資料は以下です。
翻訳は有志の方がされていますが内容はすこし古いです。
やりたいことは アプリバージョン と GitのコミットID を asstes のファイルに埋め込む事です。
src/main/assets/about-template.html:
Version:@VERSION@ (Commit:@COMMIT_ID@)
↓
src/main/assets/about.html:
Version:1.0.0 (Commit:cc2e3ac008092db2cd46b80336921471e6716af4)
結論としては app/build.gradle に以下を追加することでできました。
import org.apache.tools.ant.filters.ReplaceTokens
def getCommitId() {
try {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-list', 'master', '--max-count', '1'
standardOutput = stdout
}
return stdout.toString().trim();
}
catch (Exception e) {
println e.toString();
return "";
}
}
task makeAboutHtml {
copy {
from 'src/main/assets/about-template.html'
into 'src/main/assets/'
rename(/-template.html$/,".html");
filter(ReplaceTokens, tokens: [
VERSION: android.defaultConfig.versionName,
COMMIT_ID: getCommitId()
])
}
}
android.applicationVariants.all { variant ->
variant.mergeAssets.dependsOn('makeAboutHtml')
}
補足:
- git.exe に実行環境でパスが通っている必要があります。
- applicationVariants はエディタに Cannot resolve symbol と言われますが無視して問題ありません。
- これでだいぶ悩みました。
- 本当は build/ に直接コピーしたいのですがまだそこまでやり方がわかりません。