このエントリーは、KLab Advent Calendar 2016 の12/22の記事です。

22番手も緊張しますね。KLabGamesの基盤エンジニアのkenseiです。よろしくお願いします。

プロローグ

ある日突然事は起こります。それまで順調にビルドされていたjenkinsのandroidビルドが突然のエラー。ログを見るとstderrの項目に

trouble writing output:
Too many field references: 131000; max is 65536.
You may try using --multi-dex option.

の文字が。そう、Androidはファイル内でコードによって呼び出すことができる参照の総数で65535を超える事ができないのです。

64K を超えるメソッドを使用するアプリの設定

いつかは戦わないといけないのですが、いざその日が来ると憂鬱ですね。ちなみに宗教上の理由でmulti dexはご法度です。

まずは計測

dexのメソッド数を計測してみます。ありがたいことにツールが公開されているので、それを使用します。

dex-method-counts

$ ./gradlew assemble
$ ./dex-method-counts path/to/target.apk > method-count.txt

吐かれたログを見ると

.
.
android: 16833
com: 19806
  facebook: 3263
  google: 14115
    android: 13911
    gms: 13911 <= gpgs
.
.

androidはしょうがないとして、うむ、、GPGSでかいな。。てことで、ここを重点的に潰す事にします。

aarとの戦い

最近のandroid libraryはAAR(Android Archive)で提供されています。ただのzipですが、jarは中に入ってしまってるので解凍しないといけません。という事でみんな大好き、shellの出番です。

本当はgradleでスマートにやりたかったのですが、aarをzipTreeするとpermissionエラーを吐いてgradleが死にました。googleさん、中をゴニョゴニョしたい人の事考えてassembleして下さい。。

準備

unzipするので、Assetsと同じフォルダにディレクトリを作ります。そこにshellを作っていきます。

$ mkdir -p Target/shrink-jar-task && touch shrink-jar.sh && chmod u+x shrink-jar.sh

まずはおまじない

#!/bin/bash

# 変数の準備
WORKSPACE=$(pwd)
WORK_DIR="${WORKSPACE}/tmp"
GRADLE_WORK_DIR="${WORKSPACE}/lib"
PLUGIN_DIR="${WORKSPACE}/../Assets/Plugins/Android"

# 優しさ
while getopts v OPT
do
  case $OPT in
    v ) DEBUG="true"
        ;;
  esac
done

# おしおき
if [ "${JAVA_HOME-undefined}" = "undefined" ]; then
    echo "JAVA_HOME not set"
    echo "alias emacs='vim'" >> .bash_profile
    exit 1
fi
if [ "${ANDROID_HOME-undefined}" = "undefined" ]; then
    echo "ANDROID_HOME not set"
    echo "alias emacs='vim'" >> .bash_profile
    exit 1
fi

# ワーク作る
if [ -d $WORK_DIR ]; then
  rm -rf $WORK_DIR
fi
mkdir $WORK_DIR
if [ -d $GRADLE_WORK_DIR ]; then
  rm -rf $GRADLE_WORK_DIR
fi
mkdir $GRADLE_WORK_DIR

だいたいコメントの通りです。途中でJAVA_HOMEとANDROID_HOMEを設定せずにビルドをしようとする不届き者にemacsを使えなくするお仕置きをしています。vimを使ってればお仕置きされずに済んだのに。。

準備その2

shellでやっていますが、一部の処理はgradleを使用するのでhomebrew等でインストールを済ませておいて下さい。あ、言うの遅くなりましたが、動作はmacでしか確認していません。

上記で用意したフォルダにbuild.gradleファイルを作成し、下記の内容を設定します。

$ vim build.gradle
task wrapper(type: Wrapper) {
    gradleVersion = '2.14.1'
}

設定後にbuild.gradleファイルのある場所で

$ gradle wrapper

を実行します。

aarを解凍

Assets/Plugins/Androidの下にあるaarを解凍していきます。

echo "unziping..."
find $PLUGIN_DIR -type f -name "*.aar" -exec basename {} \; | sed -e "s/.aar//" | xargs -I{} unzip ${PLUGIN_DIR}/{}.aar -d ${WORK_DIR}/{} >/dev/null 2>&1
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | xargs -I{} rm -rf ${PLUGIN_DIR}/{}

ちなみにmaxdepthオプション付けてディレクトリをfindすると、rootが入って来てしまうのですが、mindepthを重ねがけするとrootを除外できるのを今回発見しました。他のスマートなやり方あるかもしれないので、もっと綺麗なshellを書けるようになりたい。。

aarを削除

もう使用しないので、aarを消していきます。

echo "remove plugin aar..."
if [ "$DEBUG" == "true" ] ; then
    find $PLUGIN_DIR -type f -name "*.aar*" -not -name 'appcompat-v7*.aar' -not -name 'support-v4-*.aar' -exec echo "  "{} \;
fi
find $PLUGIN_DIR -type f -name "*.aar*" -not -name 'appcompat-v7*.aar' -not -name 'support-v4-*.aar' -exec rm {} \;

この時にappcompatとsupportはいったん除きます。理由は後で面倒くさいからです!

classes.jarを取り出す

解凍したaarの中からjarを取り出していきます。

echo "move classes.jar"
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -not -name 'appcompat-v7*' -not -name 'support-v4-*' -exec basename {} \; | xargs -I{} mv ${WORK_DIR}/{}/classes.jar ${GRADLE_WORK_DIR}/{}.jar
find $PLUGIN_DIR -type d -name "firebase*" -exec basename {} \; | xargs -I{} mv ${PLUGIN_DIR}/{}/libs/classes.jar ${GRADLE_WORK_DIR}/{}.jar
if [ "$DEBUG" == "true" ] ; then
  find $GRADLE_WORK_DIR -type f -name "*.jar"  -exec echo "  "{} \;
fi

ファイルが同名なので、元のフォルダ名+"jar"に名前を変更しています。また、AARファイルになっていないAndroidLibraryのjarも持ってきています。今回はfirebaseを指定しています。

classes.jarを一つのjarにまとめる

renameしたlibフォルダのjarファイル達をunzipして一つのjarファイルにまとめてしまいます。build.gradleを編集します。

apply plugin: 'java'

dependencies {
    compile fileTree(dir: 'lib', include: ['*.jar'])
}

jar {
    from configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    archiveName = 'google.jar'
}

task wrapper(type: Wrapper) {
    gradleVersion = '2.14.1'
}

shellにgradleのjarタスクを実行させます。

echo -n "make aggregation jar"
./gradlew -b build.gradle jar

-b でgradle 設定ファイルを指定しています。

full-gpgs-jar

jd-guiで生成されたjarを確認した所 一個のjarファイルにまとまりました。

proguard ruleを生成

使用していないメソッドをstripするためにproguardをかけていきます。一からproguardのruleファイルを書いていくのは面倒なので、aarの中に入ってるproguard.txtを再利用します。また、アプリ独自の設定を書いていくようにproguard-rules.proファイルを用意します。

$ touch proguard.base

shellにproguard ruleファイルを生成させます。

echo "generate proguard rule"
cat $ANDROID_HOME/tools/proguard/proguard-android.txt > proguard-rules.pro
find $WORK_DIR -type f -name "proguard.txt" | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cat {} >> proguard-rules.pro
cat proguard.base >> proguard-rules.pro

ちなみにデフォルトのproguard-android.txtもまとめて1ファイルにしてしまってます。gradleのproguardタスクでgetDefaultProguardFile関数が使えなかったので。。

jarファイルダイエット

proguardを使用して、未使用のメソッドをjarから抹殺します。androidのpluginとjavaのpuluginでタスクがかち合うので、別名のgradle設定ファイルにします。今回はproguard.gradleとします。

まずはandroidプラグインを使ってビルドを通すためにダミーのAndroidManifest.xmlを作成します。

$ mkdir -p src/main && vim src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.klab.tekitou" >
    <uses-sdk android:minSdkVersion="7" />
</manifest>

次にgradle設定ファイルを作成します。

$ vim proguard.gradle
buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.1.0'
    }
}
allprojects {
    repositories {
        jcenter()
    }
}

apply plugin: 'com.android.library'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.3"

    defaultConfig {
        minSdkVersion 14
        targetSdkVersion 23
    }
}

task clearJar(type: Delete) {
    delete '../Assets/Plugins/Android/google.jar'
}
task proguard(type: proguard.gradle.ProGuardTask) {
    configuration 'proguard-rules.pro'
    injars 'build/libs/google.jar'
    outjars '../Assets/Plugins/Android/google.jar'
    libraryjars System.getenv("JAVA_HOME") + '/lib/rt.jar'
    libraryjars System.getenv("JAVA_HOME") + '/lib/jce.jar'
    libraryjars System.getenv("ANDROID_HOME") + '/platforms/android-23/android.jar'
    libraryjars System.getenv("ANDROID_HOME") + '/extras/android/m2repository/com/android/support/support-annotations/23.4.0/support-annotations-23.4.0.jar'
    libraryjars 'tmp/appcompat-v7-23.1.1/classes.jar'
    libraryjars 'tmp/support-v4-24.0.0/classes.jar'
    libraryjars '../Assets/Plugins/Android/firebase-common-9.8.0/libs/classes.jar'
}
proguard.dependsOn(clearJar)

injarsにclasses.jarをまとめたgoogle.jar、libraryjarsにjarが依存している物を指定しています。

※適当に必要そうなのを足していったので、結局どれが本当に必要なのか分かってない

appcompatとsupportは最初に作業フォルダに持ってきたけど、excludeしていたjarを使用します。support-annotationsはversionがpathに入っていますが、target sdk versionの中で一番新しいのを適当に設定しています。android.jarはproguard.gradleで指定したcompileSdkVersionを指定しています。

shellにgradleのjarタスクを実行させます。

echo "shurink jar"
./gradlew -b proguard.gradle proguard

ここはビルドに必要なlibraryjars不足によるエラーとの戦いになるので、何回もgradlewコマンドを叩いて通るまで頑張ります。

minimam-gpgs-jar

できあがったミニマムなjarファイルです。

aarの中のresourceを戻す

解凍したaarの中のリソースを戻していきます。Assets/Plugins/Androidフォルダ直下に置いて、packagingをUnityに任せます。

echo "copy unziped android resources"
if [ "$DEBUG" == "true" ] ; then
  find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} echo "  "${WORK_DIR}/{}
fi
cat <<EOS > project.properties
target=android-14
android.library=true
generated=true
EOS
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cp project.properties ${WORK_DIR}/{}/
find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} cp -r ${WORK_DIR}/{} ${PLUGIN_DIR}/{}
if [ "$DEBUG" == "true" ] ; then
  find $WORK_DIR -type d -maxdepth 1 -mindepth 1 -exec basename {} \; | grep -v appcompat-v7 | grep -v support-v4 | xargs -I{} echo "  "${PLUGIN_DIR}/{}
fi

project.propertiesを追加しないとUnityにAndroidResourceとして認識されないので、追加しています。targetはPlayer SettingsのMinimum API Levelを設定すれば大丈夫だと思います。generated=trueとしてるのは、aarのバージョンアップ時にフォルダを消すためのマーキングです。

きちんとAndroidResourceとして認識されているかはUnityAppPath/Temp/StagingArea/android-librariesを見ると確認できます。

proguardファイルの設定

上記で生成したjarファイルはミニマム過ぎて動きません。試しにfirebaseを全部stripから覗いてみます。

$ vim proguard.base
-dontwarn android.support.v4.app.**
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**

-keep public class com.google.firebase.**

ついでにwarningがうるさいので切っています。

  • aarはresourceが未コンパイルなので、R.classがないエラーが出るので。
  • R.$のエラーだけ出るようになるまではdontwarnは設定しない

実行してみると

add-firebase

firebaseが復活しているのが分かります。

ここからはUnityでAndroidビルド&&実機で動かしながら動作確認 => 落ちた所でログを確認して、必要なクラスをproguard.baseに足してshellを流す の作業の繰り返しになります。

エピローグ

vimを使いましょう

明日はTech Blogなのに、泣かせるので評判な@hhattoです。