In-App Review API の導入とトラブルシューティング

In-App Review API とは?

f:id:go_takahana:20210410121245p:plain:w180

アプリ内でPlayストアのレビューができる機能です。
これまではユーザをPlayストアに移動させてレビューしてもらっていましたが、この In-App Review API を使うと、Playストアに移動することなくアプリ上でレビューすることができます。

また多くのアプリはレビューを訴求するUIを作成していましたが、この In-App Review API を用いるとアプリ開発者は新しくUIを作ることなく、数行のコードでレビューダイアログが表示できます。

In-App Review API の導入はとても楽で、ユーザもレビューしやすくなるため、とても便利な機能です。ただ、いくつか制限があったり、ハマりポイントがあったりするので注意が必要です。

In-App Review API の制限

割り当て

参考

  • レビューダイアログはリクエストするたびに毎回表示されるのではなく、一度表示されたら一定期間表示されない(具体的な期間は明示されていない)

また、割り当ては変更される可能性があるので、独自のロジックを設けたほうが良いです。 (例:最後にリクエストしてから60日後に表示するなど)

レビューダイアログの状態

  • レビューダイアログが表示されたことの確認はできない
  • レビューが送信されたことの確認はできない

これらは、適切なレビューをもらうために必要な制限です。
例えば、★5でレビュー送信してくれたユーザに報酬(例:コインなど)を与えるようなことができてしまうと、アプリの適切な評価にはなりません。
また、レビューダイアログをリクエストする前にユーザに質問する(「このアプリを気に入りましたか?」など)のはガイドラインに反します。
その他、細いガイドラインがあるので確認しましょう。

In-App Review API の導入

In-App Review API は Play Core Library の機能です。
core-ktxは必須ではありませんが、後に紹介するレビューダイアログをリクエストする処理をコルーチンで書くことができます。
最新のリリースはPlay Core Library のリリースノートを確認してください。

ここでは簡単にActivityの拡張関数を作りました。なお、ドキュメントで紹介されているものとは異なり、コルーチンを使っています。

kotlinバージョン: 1.3.72
dependencies {
    implementation 'com.google.android.play:core:1.10.0'
    implementation 'com.google.android.play:core-ktx:1.8.1'
    ...
}
suspend fun Activity.requestInAppReview() {
    val manager = ReviewManagerFactory.create(this)
    val reviewInfo = manager.requestReview()
    manager.launchReview(this, reviewInfo)
}

NOTE

  • 関数内で val manager = ReviewManagerFactory.create(this) として ReviewManager を作っているが、ReviewManager をDIしても良い
  • val reviewInfo = manager.requestReview() のように、ReviewInfo の取得はリクエストする直前に実行する(参考

動作テスト

参考

通常のビルド・実行では、レビューダイアログをリクエストしても毎回表示されないのでテストは困難です。(割り当ての制限がある) テスト用にレビューダイアログを毎回表示させるためには「内部テストトラック」または「内部アプリ共有」を利用します。 ここでは内部テストトラックの例を示します。

内部テストトラック

Playコンソールで内部テストトラックを設定します。
テスターなど設定できたら、テスターにリンクを配布します(画面下の[リンクをコピー]をクリック)。

f:id:go_takahana:20210410110231p:plain
内部テストトラック

テスターがリンクを開くと、次のような画面になります。 [download it on Google Play]をクリックすると、Playストアに飛びます。ここで、アプリ名に(内部テストベータ版)になっていることが確認できます。

f:id:go_takahana:20210410112801p:plainf:id:go_takahana:20210410113353p:plain
内部テストプログラムへようこそ

アプリをインストールして、レビューダイアログが表示されることを確認します。
一度Playストアからインストールしたら、それ以降はAndroidStudioでビルド・実行しても割り当ての制限がなく、リクエストするたびにレビューダイアログが表示されます。

f:id:go_takahana:20210410121245p:plain:w280
レビューダイアログ

トラブルシューティング

ここまでやってもレビューダイアログが表示されない場合は、トラブルシューティングを必ず確認しましょう。

ここからは私がハマったポイントを紹介します。

Playストアで「あなたは内部テスターです」の表示がない

Case 1

リンクを開いて、[download it on Google Play]をクリックしてPlayストアに移動しても、「あなたは内部テスターです」の表示がない場合があります。 この場合、リンクを開いた画面の右上にある白い四角をクリックして、テスターのアカウントを選択することで解決しました。

f:id:go_takahana:20210410112801p:plain:w280

Case 2

Playストアにログインしているアカウントがテスターのアカウントではない場合があります。Playストアのホーム画面の右上アイコンがテスターのものになっていることを確認しましょう。

f:id:go_takahana:20210410123139p:plain:w280

ダイアログが開いたと思ったら、すぐに消える

スクリーンショットは用意できませんでしたmm)

この事象は、テスターのアカウントがエンタープライズアカウント(会社のアカウント)だったため起きていました。 業務でIn-App Review API を導入する時にハマる人がかなり多いと思います!気をつけましょう!

最後に

In-App Review API は数行で導入ができるので、とても便利です。
ただ、割り当てやガイドラインを把握したり、レビューダイアログを出すタイミングをよく検討したりする必要があります。
良いタイミングを考えるのは容易ではありませんが、In-App Review API を導入する際に「ユーザがサービスの何に満足しているのか」「ユーザがストレスを感じるのはどこか」など、ユーザ体験の振り返りをする良い機会になると思っています。

ちなみに、私は「ユーザが一番満足していて、その体験が一区切りしたタイミング」の時に出すのが良いと思っています。 (例:YouTubeの動画を見終わって、ホームに戻ってきたタイミングなど)

ActivityのisFinishingで破棄される理由を確認する

ActivityのisFinishingでわかること

公式ドキュメントのonDestroyの章では、以下2つのシナリオを区別するのに用いると書かれています。

  1. アクティビティが終了する(ユーザーがアクティビティを完全に閉じるか、アクティビティに対して finish() が呼び出さたことによる)。
  2. 構成の変更(デバイスの向きの変更やマルチウィンドウ モードなど)に伴いアクティビティが一時的に破棄される。

つまりは、Activityの破棄が構成の変更によるものか、それ以外かがわかります。 isFinishingtrueになるのは、明示的にfinish()をプログラム中で呼び出す他、ユーザのバックキー動作やアプリを閉じる動作をした場合です。

isFinishingfalseになる場合

状況 動作確認
画面回転をした時
マルチウィンドウに変わる時

isFinishingtrueになる場合

状況 動作確認
finish()呼び出し
バックキー押下時
実行中アプリ一覧から閉じられた時※1

※1 実行中アプリ一覧から閉じられた時

f:id:go_takahana:20210315090523g:plain:w320
実行中アプリ一覧から閉じられた時

typealiasからinline classに置き換えて代入互換性をなくす

inline class を知った

inline class UserId(val value: String)

Kotlin 1.4.30 のリリース記事を読んで、inline classを知ったのでドキュメントを読んでみました。

個人的にかなり有用に感じたのは、typealiasと違ってassignment-compatible(代入互換性)がない点です。

個人開発アプリでtypealiasを使っているところがあるので、そこを置き換えつつ、その有用性を体感してもらえたらと思います。

実行環境

  • Kotlin バージョン 1.4.30

修正前のコード

それぞれtypealiasRoomIdUserIdを定義しています。 どちらもString型であることには変わりませんが、data classなどに列挙したときに役割がパッと見やすくなります。

しかしパッと見やすくなるだけで、Value Object として機能していないので注意です。

typealias RoomId = String
typealias UserId = String

data class Room(
    val id: RoomId,
    val ownerId: UserId,
    ...
)

typealiasには代入互換性がある

以下のように、RoomRepositoryを実装しました。 roomIdを引数にとって、指定したRoomのデータを取得するような実装のつもりです。

interface RoomRepository {

    suspend fun get(roomId: RoomId): Room
}

以下は、呼び出し元の実装例です。

実装例①

問題なく動作します。

fun getRoom(roomId: RoomId, userId: UserId) {
    viewModelScope.launch {
        val room = roomRepository.get(roomId)
    }
}

実装例②

実装例①と違ってroomRepository.get(...)の引数にuserIdを渡していますが、問題なく動作します(!?)。

roomIduserIdは別の型であるかのように見えますが、実際はどちらもString型なので代入ができてしまいます。 このままだと意図しない代入が起きてしまいます。

fun getRoom(roomId: RoomId, userId: UserId) {
    viewModelScope.launch {
        val room = roomRepository.get(userId)
    }
}

修正後のコード

inline classUserIdRoomIdを作成します。 UserIdRoomIdの型を使っているところに変更はありません。 これによって、実装例②のコードはエラーになります。

inline class UserId(val value: String)
inline class RoomId(val value: String)

data class Room(
    val id: RoomId,
    val ownerId: UserId,
    ...
)

まとめ

typealiasは代入互換性があるので、意図しない代入ができてしまいます。

一方で、inline classは代入互換性がありません。これによって意図しない代入を防ぐことができ、Value Object として扱うことができます。

typealiasのままでも注意していればよくない?」という方もいるかと思いますが、言語の仕組みを利用してヒューマンエラーが起こらないようにした方がより不具合がない開発ができると思います。

Jar出力して実行したら、resources内のファイルが見つからない

resources内のファイルを読み込み

以下のコードは、KtorでFirebaseの初期化をするためにfirebase-adminsdk.jsonFileInputStreamで読み込んでいます。

// Application.kt
fun Application.initializeFirebaseApp() {
    val serviceAccount = FileInputStream("resources/firebase-adminsdk.json")
    val databaseUrl = "DATABASE_URL"
    val storageUrl = "STORAGE_URL"

    val options = FirebaseOptions.builder()
        .setCredentials(GoogleCredentials.fromStream(serviceAccount))
        .setDatabaseUrl(databaseUrl)
        .setStorageBucket(storageUrl)
        .build()

    if (FirebaseApp.getApps().isEmpty()) FirebaseApp.initializeApp(options)
}

このコードを実行するApplication.ktfirebase-adminsdk.jsonは以下のように配置しています。 f:id:go_takahana:20210307112845p:plain

IDEでビルドしてデバッグしている分には問題なく動作しますが、Jar出力して実行するとjava.io.FileNotFoundExceptionがスローされてしまいます。

Caused by: java.io.FileNotFoundException: resources/firebase-adminsdk.json (No such file or directory)

解決方法

FileInputStreamを取得するコードを修正します。

// Application.kt
fun Application.initializeFirebaseApp() {
       val serviceAccount = javaClass.classLoader.getResourceAsStream("firebase-adminsdk.json")
        // ...
}

また、build.gradle.ktssourceSetsの設定が必要です。

kotlin.sourceSets["main"].kotlin.srcDirs("src/main")
sourceSets["main"].resources.srcDirs("resources")

getResourceAsStreamとは?

実装に必要なリソースファイル(テキスト、イメージ)を読み込むことができます。 ドキュメントでは「位置に依存しない方法でのリソースへのアクセス」の方法として説明させています。

今回のようにフォルダ構造に合わせてパスを指定して問題があるときは、getResourceAsStreamを使うのが良さそうです。

そもそも絶対パス相対パスで指定していると、リファクタしにくいので適切にsourceSets及びresourcesを使用した方が良さそうです。