ANR調査とその対策のお話

はじめに

お久しぶりです。 Androidチームで活動している阿部です。 前回投稿からおよそ1年、iOSでビルド速度の改善など様々な経験を積んで、Androidへコンバートしています!

今回は、Androidアプリのパフォーマンス指標としてしばしば話題に上がるANR(Application Not Responding) についてお話しできればと思っています。

弊社でもこのANRをAndroidチームが追うべきKPIの一つに設定しているのですが、昨年の10月あたりに上昇トレンドになっており問題になっていました。

調査方法と調査結果からの仮説

調査

Androidのdeveploper向けのドキュメントで紹介されているANRの要因としては以下のものが挙げられています。

  1. メインスレッドのコードが遅い
  2. メインスレッドの IO
  3. ロックの競合
  4. ブロードキャスト レシーバが遅い

参考:https://developer.android.com/topic/performance/vitals/anr?hl=ja

しかし、どの要因がANRの起因になっているかはlogcatを使用したデバッグではなかなか見つけづらくなっています。 さらに端末の性能の差によって発生する可能性があるANRもまちまちです。

そこで、今回はStrictMode(厳格モード)を使用した調査方法を採用していくことにしました。 厳格モードはlog形式で警告を出してくれるため、問題があると思われる違反を容易に判別が可能です。

調査は以下の手順で行いました。

  1. コンソールANRから発生件数が多い順に抽出
  2. 上位五件でActivityが特定可能なものを選択
  3. StrictModeを適用し、上記画面で検証を行う

結果と仮説

まずは一部抜粋した結果をご覧ください。

  • SQLiteに関する違反が表示された

  • SharedPreferencesについても違反が表示
    • これに関しては、1292msもかかっている様です

上記のようにandroid.os.strictmode.DiskReadViolationが多発しており、どうやらローカルDBへのアクセス中に問題がある様です。 AndroidのローカルDBをSQLiteからRoomに置き換えてみたの記事でも書かれているように、ローカルDBへのアクセスはUIスレッドで実行されるという問題点がありました。 また、SharedPreferencesも意識しないとUIスレッドで実行できるようになっているため、ここにも問題がありそうです。

対策

上記で調査した結果、以下の二点の問題があることが判明しました。 1. SQLiteにUIスレッドでアクセスしてる 2. SharedPreferencesにUIスレッドでアクセスしている

1番目の問題に対しては、 AndroidのローカルDBをSQLiteからRoomに置き換えてみたを進めていくことで解決していきそうです。 (現時点で、移行は完了しています)

2番目の問題に関してはどうでしょう? たとえ、現存している全てのSharedPreferencesのアクセスをI/Oスレッドで行なったとしても、 SharedPreferencesがUIスレッドでアクセス可能なためメンバーの入れ替えが発生してしまった場合、 また同じ状態になってしまう恐れがあります。

そこで、確実にスレッドを切り替えることができるJetpack DataStoreを導入していくことにしました。

Jetpack DataStoreの導入

Jetpack DataStoreはKotlin コルーチンとフローを使用して、データを非同期的に、一貫した形で、トランザクションとして保存できます。 また、Preferences DataStore と Proto DataStore の2 種類がありどちらを導入するか検討する必要がありました。

  1. Preferences DataStore では、キーを使用してデータの保存およびアクセスを行います。この実装では、定義済みのスキーマは必要ありませんが、タイプセーフではありません。
  2. Proto DataStore では、カスタムデータ型のインスタンスとしてデータを保存します。この実装では、プロトコル バッファを使用してスキーマを定義する必要がありますが、タイプセーフです。

弊社では、カスタムデータ型のインスタンスとしてデータを保存できるメリットが大きいと判断しProto DataStoreを選択することになりました。

Jetpack DataStoreの実装例

  1. app/src/main/protoの配下にprotoファイル作成
syntax = "proto3";

option java_package = "**.**.**.datastore.entity"; //<- 生成ファイル吐き出す階層を指定
option java_multiple_files = true;  //<- 生成ファイルを別で吐き出すか

message DataStoreExample {
  // 各フィールドの識別子として 1, 2... というフィールド番号(タグ)が必要。
  bool boolean = 1;
  string hoge = 2;
  int32 huga = 3;
}
  1. protoファイルから生成したファイルを使える様にSerializerを追加
    • androidx.datastore.core.Serializerを実装する
object ExampleSerializer : Serializer<DataStoreExample> {
    override val defaultValue: DataStoreExample
        get() = DataStoreExample.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): DataStoreExample {
        try {
            return DataStoreExample.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: DataStoreExample, output: OutputStream) {
        t.writeTo(output)
    }
}
  1. DataStoreをラッパするManagerクラスを作成
class DataStoreExampleDataStoreManager @Inject constructor(
    context: Context,
) {
    private val Context.dataStoreExample: DataStore<DataStoreExample>
        by dataStore(
            fileName = "data_store_example.proto",
            serializer = DataStoreExampleSerializer,
        )

    private val dataStore = context.dataStoreExample

    suspend fun getString(): Flow<String> = dataStore.data
        .map { it.hoge }
        .catch { exception ->
            if (exception is IOException) {
                emit("")
            } else {
                throw exception
            }
        }
    }
    companion object {
        private var instance: DataStoreExampleDataStoreManager? = null

        fun getInstance(context: Context): DataStoreExampleDataStoreManager =
            DataStoreExampleDataStoreManager(context)
                .also { instance = it }
    }
}

※ラッパクラスは一貫性を保つためにシングルトン管理にしなければいけない。別のインスタンスからアクセスしようとするとException吐きます。

結果

上記のRoomへの移行とJetpack DataStoreの移行を小規模で行なってみた結果は以下の通りになっています

- Room移行後 -> 30日平均:0.04%減
- Jetpack DataStore移行後 -> 30日平均:0.01%増

Roomの方は効果がありましたが、Jetpack DataStoreの方は目に見えた成果が出ませんでした。 この原因としては以下の二点が考えられます。

  1. 他のリリース物によるパフォーマンスの悪化によるもの
  2. 軽微な箇所の移行のみであったため、パフォーマンスの改善幅が少なかった

また、firebaseなどのsdkもSharedPreferencesを使用しているためにUIスレッドで行なっている箇所も考慮しなければいけません。

そのため、Jetpack DataStoreの移行の改善幅は小さかったと考えられます。 今後は大きなSharedPreferencesも含め移行し、sdk使用箇所の使用スレッドの考慮していくことが重要になっていきそうです。

一方で、Jetpack DataStoreでの移行を行うことでcoroutines flowの考え方や使用スレッドの考え方を深めれるようになったのは大きな収穫です。 また、MVVMでcoroutines flowとLiveData双方向への変換が可能になったためよりスムーズな実装が可能になったと考えています。

まとめ

今回は、ANRの調査からその対策までを簡単にご紹介していきました。 結果としては、大きな改善には至らなかったのですが、Jetpack DataStoreを導入したことによりUIスレッドをブロックしないように作れるためパフォーマンスも良いアプリになっていくだろうと思っております。

またANR自体も減少したとはいえ、ロックの競合やブロードキャストレシーバが遅いなどの警告も存在していたため、まだまだ改善していく必要がありそうです。

最後に

ジモティーでは一緒に課題を解決してくれるエンジニアを募集中です!
jmty.co.jp