コードを変更せずにデバッグメニューでAndroidアプリの動作を変更する

Shibuya.apk #20 の資料とCyberAgent Developers Advent Calendar 2017の9日目です。
今日はAbemaTVで利用しているデバッグメニューの仕組みについて紹介したいと思います。

背景

アプリのデバッグやQAによるテスト中にはバグ修正などさまざまな作業があります。
よくあるのが、『めったに出ないレビューを訴求するダイアログをデバッグ中は毎回出したい』『デバッグ中にサーバー環境を切り替えたいという』などという一時的な変更があります。
そのために作業を中断し、ブランチを切り替え、編集して動作を確認し、ビルドし直してapkを渡して、作業に戻っているとあっという間に時間が過ぎてしまいます。

そういうタイミングでよくやる方法としてはデバッグメニューを実装して、
デバッグ時にデバッグメニューで動作を変更できるようにして、テストを楽にします。
例えばデバッグ時のみ通知を表示し、そこからデバッグメニューの画面に遷移して、そこで変更できるようにするイメージです。

デバッグメニューの実装

例えば、以下の様な場合でチュートリアルを毎回出したい場合、みなさんはデバッグメニューでの変更を実装する時どのように実装するでしょうか?

チュートリアル判定ロジックのコード例:

    if (isShowTutorialTiming()) {
        showTutorial()
  }


private fun isShowTutorialTiming(): Boolean {
    // 次のタイミングまで表示しない
    if (System.currentTimeMillis() < pref.getNextTutorialtiming()) {
        return false;
    }
    // 3回しか表示しない
    if (3 < pref.getTutorialCount()) {
        return false;
    }
    return true;
}

一つの方法としてはデバッグメニューを作ってPreferenceなど必要となるものを変更して、見れるようにするというものがあります。

pref.setNextTutorialtiming(currentMills + 100000)
pref.setTutorialCount(0)

その場合は1度は表示できますが、何度も表示したりはできません。何度も変更し直す必要が出てきます。
また全てのものがPreferenceのように変更できるとは限りません。

もう一つの方法としてデバッグ時だけisShowTutorialTimingの動作を変更してしまうというものがあります。
この方法の場合はプロダクションのコードに手を入れていくことになり、デバッグ用のコードをプロダクションにたくさん入れて汚してしまいます。

動作を変更できるようにしたコード例:

        if (isShowTutorialTiming()) {
            showTutorial()
        }
    }

    fun isShowTutorialTiming(): Boolean {
        // ******ここから追加******
        if (BuildConfig.DEBUG) { 
            if(pref.isAlwaysShowTutorialTiming()){
                return true;
            }
        }
        // ******ここまで追加******
        // 次のタイミングまで表示しない
        if (System.currentTimeMillis() < pref.getNextTutorialtiming()) {
            return false;
        }
        // 3回しか表示しない
        if (3 < pref.getTutorialCount()) {
            return false;
        }
        return true;
    }

AspectJ

ところで、アスペクト指向プログラミングをご存知でしょうか?
"アスペクト指向プログラミングは、オブジェクト指向ではうまく分離できない特徴を「アスペクト」とみなし、アスペクト記述言語をもちいて分離して記述することでプログラムに柔軟性をもたせようとする試み。"だそうです。
つまりデバッグで値を変えたいというのをアスペクトとして分離できたら良さそうに見えます。
それをするのがAspectJです。AspectJは"AspectJは、Javaに対するアスペクト指向プログラミングのための拡張。"だそうです。(Wikipediaより)
例えば、AspectJではon〜で始まるメソッドを実行する前にログを出力するというのが以下のコードだけで書くことができます。これはJavaのクラスファイルのバイトコードにウィービング(Weaving)、処理を織り込むことで実装されています。

AspectJのコード例:

@Aspect
public class AspectExample {
    @Before("execution(* on*(..))")
    public void before() {
        System.out.println("before !!");
    }
}

AspectJを用いたアプローチ

同様にデバッグのための処理はちらばってしまうので、アスペクトとして分離できたら良さそうです。
AspectJを使ってこれを分離してみましょう。
@DebugReturnアノテーションを作成して、それをつけてあげています。

@DebugReturnのみをつけたコード例:

if(isShowTutorialTiming()) {
    showTutorial()
}

@DebugReturn
fun isShowTutorialTiming() :Bool {
    // 次のタイミングまで表示しない
    if (System.currenTimeMills() < pref.getNextTutorialtiming()){
        return false;
    }
    // 3回しか表示しない
    if (3 < pref.getTutorialCount()){
        return false;
    }
    return true;
}

そしてdebug/のビルドバリアントに以下のファイルを置いてあげます。

AspectJのコード例:

@Aspect
public class DebugAspect {
    @Around("execution(* *.*(..)) && @annotation(com.github.takahirom.DebugReturn)")
    public Object debugReturnMethod(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        return DebugPreference.getInstance().isDebugShowTutorialTiming();
    }
}

これだけのコードで振る舞いを自由に変更できるようになりました!!

Debug Alter

ただこの実装をしていくのは辛いと思いますし、AspectJを入れるのもけっこう大変だったので、Gradleプラグインとしてこの部分をライブラリ化しました。DebugAlterという名前にしました。スターしてください。
https://github.com/takahirom/debug-alter

使い方

実装のイメージとしては以下のような形になります。といってもわかりにくいと思うので、コードで説明していきます。

返り値を変更したいところで@DebugReturnを使うことができます。リリースに入るのはアノテーションのみとなります。
以下ではスナックバーを出すかどうかの判定とスナックバーのテキストを取得して表示しています。

app/src/main/java/com/../MainActivity.kt

    class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)

            val fab = findViewById<FloatingActionButton>(R.id.fab)
            fab.setOnClickListener { view ->
                if (isSnackbarShowTiming()) {
                    Snackbar.make(view, getSnackbarText(), Snackbar.LENGTH_LONG)
                            .setAction("Action", null).show()
                }
            }
        }

        @DebugReturn
        fun isSnackbarShowTiming(): Boolean {
            return false
        }

        @DebugReturn
        fun getSnackbarText(): String {
            return "bad"
        }

@DebugReturnをつけたメソッドを設定するために、Debug用のアプリケーションクラスを作成します。

app/src/debug/AndroidManifest.xml

    <application
        android:name=".DebugApp"
        tools:replace="android:name"
        />

そして以下のようにDebugAlterItemを使って@DebugReturnで変更したいところを設定していきます。
@DebugReturnのメソッドが呼び出されたときにDebugAlterItem#isAlter()が呼び出され、falseを返すと何も変えずに@DebugReturn
がついたメソッドを呼び出し、trueを返すとDebugAlterItem#get()が呼び出されます。ここではSharedPreferenceを使って設定しています。

app/src/debug/java/.../DebugApp.kt

// Extends your main Application classs
class DebugApp : App() {

    override fun onCreate() {
        super.onCreate()

        val preference = PreferenceManager.getDefaultSharedPreferences(this)
        val items = arrayListOf<DebugAlterItem<*>>(
                object : DebugAlterItem<String>("getSnackbarText") {
                    override fun isAlter(): Boolean = preference.contains(key)
                    override fun get(): String? = preference.getString(key, null)
                },
                object : DebugAlterItem<Boolean>("isSnackbarShowTiming") {
                    override fun isAlter(): Boolean = preference.contains(key)
                    override fun get(): Boolean? = preference.getBoolean(key, false)
                })

        DebugAlter.getInstance().setItems(items)

これで設定できました。
あとはこのSharedPreferenceを使って、デバッグメニューを実装してあげればうまく動いてくれるはずです!
具体的なdependenciesなどのbuid.gradleの書き方などは以下に書いてあります。
https://github.com/takahirom/debug-alter

まとめ

  • デバッグメニューを実装する時にAspectJを使うとプロダクションのコードを汚さずに実装していけます。
  • 手軽にやるならDebugAlterを使ってみてください。(実装としてはかなり薄いです)
  • 以下のリポジトリをスターしてください。 
    https://github.com/takahirom/debug-alter