開発中のアプリのフィードバックを素早く得るための仕組みづくり

開発中のアプリのフィードバックを素早く得るための仕組みづくり

ZOZOTOWN開発本部でAndroidのテックリードをやっているいわたんです。最近はでっかいモンスターをハントするゲームにハマっており、夜な夜な一狩りしてます。

今回は、私たちのチームで行っている業務効率化の一例を紹介します。

背景・課題

私たちのチームでは、デザイナーやプロジェクトマネージャーによる動作確認のためにDeployGateとGoogle Play Consoleの内部テストを使用しています。また、QAチームによるテスト時の不具合や日々のタスク、プロジェクトマネージャーやデザイナーからのフィードバックの修正管理にJiraを利用しています。

しかし、現状のフィードバック収集プロセスには以下の課題がありました。

  • QAチームからの不具合チケットの起票
    • 開発中のデバッグ情報やログイン状態などのアプリ内情報や、実行時のログ情報を添付するのに手間がかかる。
  • デザイナーやプロジェクトマネージャーからのフィードバック
    • Slackでフィードバックを受けることが多く、情報が散逸しやすい。
    • Jiraに慣れていないメンバーにとって、課題起票や情報添付のハードルが高い。

これらの課題により、フィードバックの収集に時間がかかり、効率が低下していました。

解決方法

フィードバックの収集を効率化するために次の仕組みを構築しました。

  • フィードバックを手軽に送信できるようにする
  • 必要な情報を自動的に収集する
  • DeployGateからインストールしたときのみフィードバックを送信できるように、別アプリからフィードバックをする

全体の流れ

  1. ZOZOTOWNアプリのスクリーンショットを撮影するとDeployGateのキャプチャ機能が起動する
  2. DeployGateのキャプチャ機能が完了するとDeployGateからZOZOTOWNアプリへコールバックが呼び出される
  3. ZOZOTOWNアプリがコールバック内でアプリの情報を自動で収集する
  4. ZOZOTOWNアプリとは別アプリとして実装されているフィードバック用アプリにIntent経由で収集した情報を渡して起動する
  5. フィードバック用アプリがIntentの内容をもとにJiraのIssueを作成する

次の章から具体的な実装方法を紹介します。

スクリーンショットをトリガーにしたフィードバック

フィードバックを簡単に送信できるよう、アプリ内で明確なトリガーを設ける必要があります。本記事ではDeployGateのキャプチャ機能を利用します。

キャプチャ機能はスクリーンショット撮影時に端末情報やLogcatを自動で収集し、DeployGate上に登録します。また、DeployGate SDKを利用することでキャプチャ完了時にアプリ内でコールバックを受け取ることができます。このコールバックを利用してフィードバックの送信処理を行います。

次に実装方法を紹介します。

まずはDeployGateのキャプチャ機能のコールバックを利用するためにバージョン4.9.0以降のDeployGate SDKをlibs.versions.tomlに追記します。

deploygate = "4.9.0"

[libraries]
deploygate = { group = "com.deploygate", name = "sdk", version.ref = "deploygate" }

続いて、app/build.gradleのdependenciesにDeployGateを追加します。

dependencies {
    implementation libs.deploygate
}

続いてDeployGateのキャプチャ機能が完了した際にフィードバックを送信するため、キャプチャ機能が完了した際に呼び出されるコールバックをApplicationを継承しているクラスに追加します。

まずはコールバックの登録だけを行い、具体的なフィードバックの送信処理は後述します。

class App : Application() {
    override fun onCreate() {
        DeployGate.registerCaptureCreateCallback { url ->
            // TODO ここでフィードバックを送信する
        }
    }
}

必要な情報を自動的に収集する

次に、フィードバックを送信する際に以下の情報を自動で収集する機能を実装します。これらの情報は、開発チームが問題を特定し修正するために必要な情報と、QAチームが課題を管理するために必要な情報を含んでいます。

  • アプリに関する情報
    • ログ
    • アプリのバージョン
    • 接続先の環境
  • ユーザー情報
    • ログイン済みかどうか
    • ユーザー名
    • UUID
  • 端末情報
    • 端末名
    • OSバージョン

ログの収集

まずは操作ログです。ストアに公開するReleaseビルドでは、パフォーマンス向上やセキュリティ対策のため、Timberなどのライブラリを使用してログの出力を抑制することが一般的です。しかし、QAにおいては詳細なログ情報が問題の原因究明に役立ちます。そこで、DeployGate経由でインストールされたReleaseビルドでのみログを出力できるように変更を加えます。

具体的には、DeployGate SDK 4.9.0で導入されたDeployGate.registerStatusChangeCallbackメソッドを利用します。このメソッドで登録したコールバックの第一引数は、アプリがDeployGateで管理されているかどうかを示すフラグです。このフラグを参照し、DeployGate経由でインストールされた場合にのみLogcatを有効化します。

class App : Application() {
    override fun onCreate() {
        DeployGate.registerStatusChangeCallback { isManaged, _, _, _ ->
            if (isManaged) {
                // ここでlogcatを有効にする
                Timber.plant(DebugTree())
            }
        }
    }
}

アプリ内情報の収集

続いてアプリ内の情報を収集します。これらの必要な情報は開発しているアプリによって異なります。

ReportBuilder は、フィードバック情報を構築するためのインタフェースであり、build()メソッドによって情報を収集し、文字列形式で返します。このインタフェースを実装するクラスを複数利用することで、必要な情報を組み合わせたフィードバック内容を生成できます。

/**
 * フィードバック情報を作成するためのインターフェース
 */
fun interface ReportBuilder {
    /**
     * 必要な情報を収集してフィードバック情報として文字列を返す
     */
    fun build(): String
}

本記事では ReportBuilder を実装した、 BasicReportBuilder クラスと DebugReportBuilder クラスを用意しました。

BasicReportBuilder クラスは全てのBuild Variantで共通して必要なユーザー情報、アプリのバージョン、端末情報などを提供します。サンプルではイメージしやすいように UserRepository を仮で用意しています。

build()メソッドでこれらの情報をフォーマットし、フィードバック情報の文字列として返します。

/**
 * すべてのBuild Variantで収集したい情報を返す
 */
class BasicReportBuilder @Inject constructor(
    private val userRepository: UserRepository,
) : ReportBuilder {
    override fun build(): String {
        val user = userRepository.get()
        val loginName = user.loginName
        val uid = user.uid
        val versionName = BuildConfig.VERSION_NAME
        val versionCode = BuildConfig.VERSION_CODE
        val buildVariant = BuildConfig.BUILD_TYPE

        return """
        【概要】

        【再現手順】

        【補足】

        【確認アカウント】
        loginName: $loginName
        uid: $uid

        【アプリ情報】
        Version: ${versionName}(${versionCode})
        Variant: $buildVariant

        【端末情報】
        Build.VERSION.SDK_INT: ${Build.VERSION.SDK_INT}
        Build.VERSION.BASE_OS: ${Build.VERSION.BASE_OS}
        Build.VERSION.RELEASE: ${Build.VERSION.RELEASE}
        Build.BRAND: ${Build.BRAND}
        Build.DEVICE: ${Build.DEVICE}
        Build.MODEL: ${Build.MODEL}
        Build.PRODUCT: ${Build.PRODUCT}
        """.trimIndent()
    }
}

DebugReportBuilder クラスはDebugビルド時にのみ必要な情報、たとえばデバッグメニューの状態や設定を提供します。これらはアプリによって異なりますのでサンプルは仮のクラスを用意しています。

これら以外にもBuild Variantによって収集する情報が変わる場合はそれぞれ必要な情報を集める ReportBuilder クラスを実装します。

/**
 * Debugビルド時に収集したい情報を返す
 * @param DebugOption デバッグ時の情報を保持するオブジェクト
 */
class DebugReportBuilder(
    private val debugOption: DebugOption
) : ReportBuilder {
    override fun build(): String {
        return """
            【デバッグ情報】
            ${debugOption}
        """.trimIndent()
    }
}

Reporter クラスは ReportBuilder を使って複数のフィードバック情報を結合し、それをIntentのエクストラとして追加する役割を担います。builderリストには複数の ReportBuilder が含まれ、それぞれのbuildメソッドの結果を結合し、1つのフィードバック情報として出力します。

/**
 * [other]で指定されたReportBuilderを後に続けて結合する
 */
fun ReportBuilder.compose(other: ReportBuilder): ReportBuilder {
    return ReportBuilder {
        build() + other.build()
    }
}

class Reporter(
    private val builder: List<ReportBuilder>
) {
    /**
     * フィードバック用アプリを起動するIntentを作成する
     */
    fun createReportIntent(
        additionalBuilder: ReportBuilder,
    ): Intent {
        return Intent(
            "com.example.report",
        ).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
            // すべてのReportBuilderを結合
            val composedReportBuilder = builder.fold(additionalBuilder) { acc, reportBuilder ->
                acc.compose(reportBuilder)
            }
            // レポートをIntentに追加
            val description = composedReportBuilder.build()
            putExtra(Intent.EXTRA_TEXT, description)
        }
    }
}

これらのクラスはHiltによる依存性注入でBuild Variantごとに異なるReporterのインスタンスを提供しています。

Debugビルド時のHiltのModuleは次のようにDebug関連の情報を集める RepoterBuilder を使用します。

@InstallIn(SingletonComponent::class)
@Module
object ReporterModule {
    @Provides
    fun provideReporter(
        userRepository: UserRepository,
        debugMenu: DebugMenu,
    ): Reporter {
        return Reporter(
            versionName = BuildConfig.VERSION_NAME,
            builder = listOf(
                BasicReportBuilder(userRepository),
                DebugReportBuilder(debugMenu),
            ),
        )
    }
}

Releaseビルド時のHiltのModuleは次のように最低限の情報を集める RepoterBuilder を使用します。

@InstallIn(SingletonComponent::class)
@Module
object ReporterModule {
    @Provides
    fun provideReporter(
        userRepository: UserRepository,
    ): Reporter {
        return Reporter(
            versionName = BuildConfig.VERSION_NAME,
            builder = listOf(
                BasicReportBuilder(userRepository),
            ),
        )
    }
}

DeployGateのキャプチャ機能が完了した際のコールバックで、作成したIntentを利用してフィードバック送信用アプリを起動します。

class App : Application() {
    @Inject
    lateinit var reporter: Reporter

    override fun onCreate() {
        // DeployGate経由でインストール時にログを有効化する
        DeployGate.registerStatusChangeCallback { isManaged, _, _, _ ->
            if (isManaged) {
                // ここでlogcatを有効にする
                Timber.plant(DebugTree())
            }
        }

        // DeployGateにスクリーンショットを保存した際にフィードバックを作成する
        DeployGate.registerCaptureCreateCallback { captureUrl, _ ->
            val intent = reporter.createReportIntent {
                """
                【スクリーンショット】
                $captureUrl
                
                """.trimIndent()
            }
            startActivity(intent)
        }
    }
}

課題を作成する機能

私たちのチームではJiraで課題管理していますが、課題を作成する実装を変更することで別の課題管理ツールを使用している場合でも応用が効くと思います。

課題を作成する機能の実装部分はチームごとで必要な実装が異なるため、本記事は他チームでも参考になりそうな部分を紹介します。

スクリーンショット

別アプリとして実装する

フィードバック機能は、JiraのAPIトークンや開発用の課題管理ツールのURL等の機密情報や、ログ取得・内部状態の出力等のデバッグ機能を含みます。これらの情報は、セキュリティやアプリのパフォーマンス、ユーザーエクスペリエンスの観点から、ストアに公開するReleaseビルドには含めるべきではありません。

そのため、Jiraへの課題作成は開発中のアプリとは別アプリとして実装します。

フィードバック内容の取得

Reporter で作成したIntentで ReportActivity を起動するようにAndroidManifest.xmlを設定します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:label="フィードバック送信">
        <activity
            android:name="com.example.ReportActivity"
            android:windowSoftInputMode="adjustResize"
            android:theme="@style/Theme.Design.NoActionBar"
            android:exported="true">
            <intent-filter android:label="@string/report_create">
                <action android:name="com.example.report" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Intentからフィードバック内容を取得し、descriptionに格納しています。このdescriptionを各チームに合わせてAPI等を呼び出してフィードバックの課題を作成します。

class ReportActivity : ComponentActivity() {
    // フィードバック内容
    val description = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
}

アクセストークンの管理

JiraのアクセストークンやHostの情報はGit上で直接管理するのはセキュリティ上の懸念があります。そこで、環境変数からBuildConfigへ登録できるよう、次のようにbuild.gradle.ktsに設定を追加します。

// 必要な部分のみ抜粋
android {
    defaultConfig {
        buildConfigField("String", "JIRA_TOKEN", "\"${System.getenv("JIRA_TOKEN")}\"")
        buildConfigField("String", "JIRA_HOST", "\"${System.getenv("JIRA_HOST")}\"")
    }
}

そして、フィードバックを入力して送信するためのActivityでBuildConfigから情報を取得します。

class ReportActivity : ComponentActivity() {
    private val jiraHost: String
        get() {
            // build.gradleの設定経由でJIRA_HOSTで定義された環境変数を取得している
            val env = BuildConfig.JIRA_HOST
            require(env.isNotEmpty()) {
                "環境変数JIRA_HOSTが空文字でビルドされています"
            }
            require(env != "null") {
                "環境変数JIRA_HOSTが未定義でビルドされています"
            }
            return env
        }

    private val jiraToken: String
        get() {
            // build.gradleの設定経由でJIRA_TOKENで定義された環境変数を取得している
            val env = BuildConfig.JIRA_TOKEN
            require(env.isNotEmpty()) {
                "環境変数JIRA_TOKENが空文字でビルドされています"
            }
            require(env != "null") {
                "環境変数JIRA_TOKENが未定義でビルドされています"
            }
            return env
        }
}

Activity破棄対応

QA期間中などに開発者向けオプションで、「アクティビティを保持しない」を有効にしているケースがあるかと思います。このオプションを有効にした状態で、フィードバック送信用アプリからQA対象のアプリを再度表示した等のタイミングでフィードバック内容が消えてしまう可能性があります。そのため、先述のReportActivityでは簡略化していましたが、rememberSaveableやonSaveInstanceState等でActivityの破棄に対応した実装が必要になります。

実際に運用した結果

実際にシステムの運用を開始し、現場での実証テストを通じて、QAチームから様々な意見が寄せられました。全体的に概ね好評であり、現場で感じた使いやすさや業務効率の向上についての具体的なフィードバックは以下の通りです。

  1. ログをスクリーンショットと同時に用意できる点
    QAチームはこれまで手動でログやスクリーンショット画像を用意し、必要に応じて添付していました。DeployGateのキャプチャ機能によって自動で同時に用意できるようになり、負担削減が好評でした。

  2. 各種情報の自動記録
    ログイン状態、UID、アプリのバージョン、接続先環境、端末情報といった重要な情報が自動的に記録されるため、従来必要であった手作業が省略でき、入力ミスも防止できる点が便利だと好評でした。

  3. Jiraの必須項目の自動記入
    各種アプリ関連情報に加え、Jira上で必須となる担当者や影響を受けるバージョンなどの定型的な入力項目についても、自動で記入される仕組みが実装されています。これにより、手動での入力作業が省け、全体の作業負担が大幅に軽減される点が非常に有用であるとの評価を受けました。

  4. 特定画面での準必須情報の自動記録への期待
    一方で、商品詳細画面における問い合わせ番号や検索結果画面での絞り込み条件など、頻繁に記載が求められる情報については機能拡張が欲しいという意見もありました。これらの情報はReporterの実装を工夫することで実現が可能と考えられるので今後追加する予定です。

まとめ

本記事ではDeployGateのキャプチャ機能を利用して、フィードバック用アプリ、そしてJiraとの連携を活用することで、従来プロセスの手作業や情報散逸の問題を解消しました。主なポイントは以下の通りです。

  1. 自動化による効率化 スクリーンショット撮影をトリガーに、ログやアプリ・ユーザー、端末情報などの必要情報を自動で収集し、手動での情報添付やPCへのデータ転送の手間を削減しました。

  2. 安全かつ柔軟な設計 機密性の高いJiraのAPIトークンやデバッグ情報は別のフィードバック用アプリで管理し、環境変数を利用することでセキュリティ面も考慮しています。

  3. 現場からの好評と今後の展開 QAチームや関係者からは、ログとスクリーンショットの同時送信による効率向上や、Jiraの必須項目の自動入力による入力ミス防止が高く評価されています。さらに、特定画面で必須情報の自動記録など、さらなる機能拡張が期待されています。

このシステムにより、開発・テスト・運用の各フェーズでのフィードバック対応がスムーズになり、結果として業務全体の効率化と品質向上が実現されました。今後は、さらなる改善と機能追加を通じて、より充実したフィードバック環境の構築を目指していきます。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー