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 と組み合わせができるともっとパワフルに使えるかもしれません。