コネヒト開発者ブログ

コネヒト開発者ブログ

Android版ママリアプリのリファクタ事情 ~ Google Play Billing Library 4導入編 ~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。前回のエントリーから、髪の毛はアップデートされておりません。

さて今回は、Android版ママリアプリの課金機能にまつわるお話をお伝えできればと思います。

はじめに

2021 年 11 月 1 日にGoogle Playのアップデートにより、アプリ内課金機能ライブラリの Billing Library で v3 以降が必須となりました。

Google Play Billing Library のバージョンのサポート終了  |  Google Play の課金システム  |  Android Developers

ママリアプリではアプリ内課金の定期購入機能で、プレミアムサービスを提供していますが、Billing Library v2に依存していたため、期日までにライブラリのアップデートをする必要がありました。 今回は、BillingLibrary v4の紹介をしつつ、ライブラリのアップデート時の課金機能のリファクタリングの事例を紹介します。

Billing Library v4の紹介

まず初めにアプリ内課金を実装するための、最新のライブラリであるBilling Library v4の使い方をを紹介をします。

接続と破棄

アプリ内課金機能を利用するには、Google Playと接続し課金処理を実行する必要があるため、まずはその準備が必要となります。

BillingClientを生成し、Google Playに接続を行います。課金アイテムの取得、購入/リストアといった課金操作は、基本的にBilingClientのメソッドを使用します。また接続を残し続けると、メモリリークが発生するため、不要になったら破棄が必要になります。

// BillingClientの生成
private val billingClient by lazy {
        BillingClient.newBuilder(context)
          .setListener(object : PurchasesUpdatedListener {
              override fun onPurchasesUpdated(result: BillingResult, list: MutableList<Purchase>?) {
                  // 課金状態が更新されると呼び出される。
              }
          })
          .enablePendingPurchases()
          .build()
}

// Google Playに接続し、課金機能の呼び出し準備をする
fun initialize() {
        billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingServiceDisconnected() {
                    // 切断が切れたら呼び出される。必要に応じて再接続処理など
        }

        override fun onBillingSetupFinished(result: BillingResult) {
                    // 初期化が完了すると呼び出される。ここが呼ばれないと課金処理が始められない。
        }
    })
}

// 不要になったら接続を切る
fun release() {
    billingClient.endConnection()
}

課金情報の取得

課金を開始する前に、現在の課金状態を確認する必要がある場合、queryPurchasesAsyncメソッドを利用して現在の課金情報を取得します。

queryPurchasesAsyncメソッドは、Google Playから現時点の課金情報をリクエストします。 契約が存在する場合、Purchaseクラスのリストが返却されるので、Purchaseクラスが提供する購読情報を参照することができます。

private fun queryPurchases() {
      billingClient.queryPurchasesAsync(
                    BillingClient.SkuType.SUBS // 課金種別の指定
            ) { result, list ->
          val responseCode = result.responseCode
          if (responseCode == BillingClient.BillingResponseCode.OK) {
                            // 成功の場合は、Purchase型がリストで返される
          } else {
                            // OK以外はエラー
          }
      }
  }

ママリアプリでは、重複課金を防ぐために、現在の課金情報を取得してバリデーションを行なっています。

課金履歴の取得

過去の課金履歴が必要な場合は、queryPurchaseHistoryAsyncメソッドを呼び出します。購入の有効期限が切れていたり、キャンセルされていても、最後に購入した購読情報が返却されます。

billingClient.queryPurchaseHistoryAsync(
        BillingClient.SkuType.SUBS
) { result, list ->
    val responseCode = result.responseCode
    if (responseCode == BillingClient.BillingResponseCode.OK) {
        // 成功の場合は、PurchaseHistoryRecordがリストで返される
    } else {
                // OK以外はエラー
    }
}

ママリアプリでは、過去の課金状態に応じて、ユーザーにお得なキャンペーンを訴求することをしています。

課金処理の実行

実際に課金を行うためには、2つの作業が必要になります。

  1. 購読に必要なSkuDetailsを取得する
  2. SkuDetailsを元に課金処理を起動する

このクラスをlaunchBillingFlowメソッドに渡すことで、課金処理が開始されます。画面操作を伴うためか、activityを渡す必要があります。

※SkuDetailsには課金アイテムの名称や価格の情報が含まれているため、ユーザーに見せるために使うことも可能です。

var skuDetails: List<SkuDetails> = emptyList()

private fun querySkuDetails() {
        val params = SkuDetailsParams.newBuilder()
            .setType(BillingClient.SkuType.SUBS)
            .setSkusList(listOf(itemType.id)) // 課金アイテムのIDリスト
            .build()
        
    billingClient.querySkuDetailsAsync(params) { result, list ->
        val responseCode = result.responseCode
        if (responseCode == BillingClient.BillingResponseCode.OK) {
            skuDetails = list // 成功するとSkuDetailsがリストで返される。このリストを元に購読処理を起動。
        } else {
        }
    }
}

private fun launchBilling() {
        val builder = BillingFlowParams.newBuilder()
        builder.setSkuDetails(skuDetails).build()
        // 購読処理にはactivityが必要
        billingClient.launchBillingFlow(activity, builder.build()) 
}

リファクタリングの方向性

冒頭で書いた通りママリアプリではv2に依存したライブラリで、定期購読の機能を実現していましたが、複数の画面や機能から購読機能を利用したいというビジネス要件や自社APIやBillingClientとの連携でステータスを管理する必要があるといった技術的な制約を実現するために、既存のコードは非常にファットな状態でした。今回はv4への置き換えと並行して課金ロジックのリファクタリングも進めていました。

これらの事情を踏まえて、最終的にBillingActivity、BillingViewModel、BillingManagerの3つのクラスに責務を分離した設計となりました。

f:id:katsutomu0124:20220304185704p:plain 処理の流れは以下の通りです。

  1. プレミアム会員登録画面で、課金ボタンをタップするとBillingActivityが起動する
    1. 半透明なActivityなため元の画面にオーバーレイされる
  2. ActivityのonCreateをトリガーにBillingViewModelがBillingManagerを呼び出しGooglePlayの課金処理を開始する
  3. BillingManagerでBillingClientのメソッドを呼び出しGoogle Playの課金処理を開始する
  4. BillingManagerが結果を元にBillingViewModelが自社APIをコール、ステータスに応じて、BillingActivityのUIを更新する

 

それぞれの役割と処理の流れをソースコードと合わせて紹介します。

BillingManager

このクラスはGooglePlayの課金機能を呼び出すことと、BillingClientの結果を元にしたステータス管理を担当しています。課金機能の初期化の成否や、Google Playの操作、課金結果の待ち受けなど複数の状態を監視する必要があるため、RxJavaのCombineLatestを利用し、複数の状態を集約してステータスを更新しています。

処理の流れは以下の通りです。

  1. BillingClientとの接続と現在の課金情報の取得を開始
  2. Activityを引渡し、GooglePlay課金の開始
  3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
// 1. BillingClientとの接続と現在の課金情報の取得を開始
fun initialize(billingType: BillingType) {
    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            val responseCode = billingResult.responseCode
            if (responseCode == BillingClient.BillingResponseCode.OK) {
                when (billingType) {
                    is BillingType.Purchase, BillingType.Restore -> {
                        querySkuDetails()
                        queryPurchases()
                    }
                }
            }
        }
    })
}

・・・・・

// 2. Activityを引渡し、GooglePlay課金の開始
fun doSubscribe(activity: AppCompatActivity, readyToPurchased: ReadyToPurchased) {
    val type = readyToPurchased.billingType
      val builder = BillingFlowParams.newBuilder()
    builder.setSubscriptionUpdateParams(
        BillingFlowParams.SubscriptionUpdateParams.newBuilder()
            .setOldSkuPurchaseToken(readyToPurchased.purchases[0].purchaseToken)
            .build()
    )
    builder.setSkuDetails(readyToPurchased.skuDetails).build()
    billingClient.launchBillingFlow(activity, builder.build())
    _purchasing.onNext(true)
}

・・・・・

// 3. Google Play課金のステータス監視。最後まで成功したら社内APIを呼び出すステータスに移行する
Observables.combineLatest(
    _initialized, // 課金手続き準備完了のステータスの監視
    _purchasing, // 課金手続き中ステータスの監視
    _billingResult // 課金結果の監視
) { initializeStatus, purchasing, billingResult ->
    var nextStatus = _status.value
    when (_status.value) {
                
                ・・・・・             

        is ReadyToPurchased -> {
                        nextStatus = // 一度Activityに準備完了を通して、課金手続き中ステータスに移行
        }
        is Purchasing -> {
                        nextStatus = // 課金完了後に社内APIのコールを行うReadyToRegisterのステータスに移行
        }
    }
    _status.onNext(nextStatus)
}.subscribe().addTo(compositeDisposable)

BillingViewModel

このクラスはGooglePlayの課金処理の呼び出し社内APIの登録と、その間の状態変化をActivityに伝える責務を担当しています。処理の流れは以下の通りです。

  1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する.
  2. BillingManagerのステータスを監視し、UI表示をActivityに通知する。
  3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
        billingManager.status.distinctUntilChanged().subscribe { status ->
            when (status) {
                        // 1. BillingManagerの初期化完了を待ち、課金機能の開始を指示する
                is BillingManger.ReadyToPurchased -> { startPurchase.postValue(status) }
                        // 3. GooglePlay課金完了後、社内APIに課金情報を送信し、ユーザーをプレミアム会員に昇格する。
                is BillingManger.ReadyToRegister -> { registerReceipt(status.purchasedData) }
            }

                // 2. BillingManagerのステータスを監視し、UI表示をActivitiyに通知する。
        // ※ユーザー操作ブロックのためのローディング表示
            showProgress.postValue(
                status is BillingManger.Initializing ||
                    status is BillingManger.ReadyToPurchased ||
                    status is BillingManger.ReadyToRegister
            )
        
        }.addTo(compositeDisposable)
}

 // 不要になったら解放
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
    billingManager.release()
    compositeDisposable.clear()
}

BillingActivity

このクラスはUI操作をViewModelに伝えることと、状態に応じたUI操作を担当しています。処理の流れは以下の通りです。

  1. 課金機能の画面で課金ボタンやリストアボタンのタップをトリガーに、Activityが起動する。
  2. GooglePlay課金の準備が完了したら、BillingActivity自身を渡して課金処理を開始する
  3. 課金処理が終了するまでBillingViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
// 1. 課金機能の画面で課金ボタンやリストアボタンをタップをトリガーに、Activityが起動する。
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

                // 2. GooglePlay課金の準備が完了したら、自分自身を渡して課金処理を開始する
                viewModel.startPurchase.observe(this) {
            viewModel.onSubscribe(this, it)
        }

                // 3. 課金処理が終了するまでViewModelのステータス更新を監視し、状況に応じてUIの更新を行う。
                // ローディング表示
        viewModel.showProgress.observe(this) {
            if (it) {
                binding.progress.toVisible()
            } else {
                binding.progress.toGone()
            }
        }
                // プレミアムユーザー昇格の通知
                viewModel.purchaseRegistered.observe(this) {
            Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG)
                .show()
        }
    }

抜粋した内容となりますが、以上のように、3つのクラスに分けて責務を分離し、それぞれの役割を明確にすることで、見通しの良いコードになるようにリファクタリングを進めました。大規模なリファクタリングとなったため、アップデート後の不具合が懸念されましたが、アップデート以降課金に関係したトラブルは発生せず、数ヶ月が経過したので安心しています。

※もちろん事前に全ての課金機能の動作確認は実施しています。

おわりに

今回はライブラリアップデートのきっかけに、課金機能のリファクタリングを行なった事例をお伝えいたしました。前回までに紹介したリファクタリングの方針が、課金機能のリファクタリング後に決まったので、一部既存の理想系と外れる部分もありますが、今後も継続的に改善を進めていく予定です。最後までお読みいただきありがとうございました!

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

「こんなところも?」 CakePHP4・phpunitのアップグレードに伴う変更箇所

こんにちは!

Webエンジニアをやっている西中と言います。

弊社では開発組織として運用しているサービスのフレームワークのアップグレード対応を定期的に行っています。

今回は私がCakePHP4.3対応をしていった中で躓いたポイントをいくつかピックアップしていきたいと思います。

CakePHP4.3にアップグレードするにあたって、phpunitのバージョンも8.0にアップグレードしました。

CakePHP4自体のアップグレード対応は一括で対応できるものが割と多い印象で、個人的にはphpunit周りの変更の方がインパクトが大きいのではないか…?と思っています。

assertArraySubsetが廃止!!

正に衝撃! phpunit8で廃止、phpunit9で削除! 良い感じに2つの配列を比較してくれるこのAssertion。 同じ機能が欲しいなら拡張機能として作れ(使え)ということですが、 どういう値を比較してどういう状態であるべき か、テストケースを見直す良い機会と捉えてテストケースを見直しました。

Fixtureの責務の分離

CakePHP4.3以降ではFixtureからスキーマ定義の責務が分離されました。 今まではFixtureファイルの中にテーブルの列定義を宣言する必要があったため、テーブルの変更とともにFixtureファイル内の列定義も更新する必要がありました。

CakePHP4.3以降ではFixtureの責務は「テストデータの定義」のみとなり、CakePHPのMigration機能を利用しているのであれば、そのまま反映させることが出来るようになりました。

また、SQLのダンプファイルを元にテスト用のスキーマを作成するという方法も取ることが出来るので、より柔軟に開発ができるようになっています。

弊社ではスキーマの定義をアプリケーション外で管理しているため(参照:AWS × slackを用いたDDL自動実行フローを構築しました - コネヒト開発者ブログ)、今回はDDLファイルをテスト実行時に読み込むというやり方を取っています。

アップグレードマニュアルにあるように、DDLファイルを読み込むだけでOKなので運用・管理のコストはそこまで高くありません。

// in tests/bootstrap.php
use Cake\TestSuite\Fixture\SchemaLoader;

// Load one or more SQL files.
(new SchemaLoader())->loadSqlFiles('path/to/schema.sql', 'test');

Test内のRoutingが効かない?

CakePHP4.x系はテスト時にRoutingが自動的に読み込まれないので、 Testクラスの setUp メソッドなどでRouting設定を読み込む必要があります。

public function setUp(): void
{
    parent::setUp();
    // Routingの初期化
    $this->loadRoutes();
}

今回はちょっと見落としがちな3つの変更点を紹介させていただきました。

他の弊社メンバーの投稿にもCakePHP4へのアップグレードに関する記事があります。 今後もCakePHP4系へのアップグレードに関する記事が投稿されると思うので、同じように悩まれている方の参考になれたらとても嬉しいです。

Webpack5 にバージョンアップしました。

こんにちは! フロントエンドエンジニアの もりや です。 今回はママリのアプリ内で使われている WebView の Webpack を v4 から v5 にアップデートしたので、その事例を紹介します。

Webpack5 は2020年10月にリリースされたので、特に目新しい情報はありませんが、1つの事例として読んでいただければ幸いです。

はじめに

今回のアップデートは、以下2つの公式ドキュメントを参考に進めました。

リリース情報を見たところ、Webpack5 で破壊的な変更はなく、Webpack4 系の最新で非推奨のメッセージが出ていなければアップデートできるようです。 実際にやってみたところ、Webpack 本体は割と簡単にアップデートできました。

ただし、プラグインやローダーのアップデートでちょっと手間がかかりましたので、そのあたりを中心に紹介します。

アップデートの流れ

  1. Webpack4 と webpack-cli を最新に上げる
  2. プラグイン、ローダーを可能な限り最新に上げる
  3. Webpack5 にアップデート
  4. プラグイン、ローダーを最新に上げる
  5. 動作確認

1. Webpack4 と webpack-cli を最新に上げる

まず Webpack を v4 系の最新にアップデートします。 2022-02-01 時点で最新は以下のようになっていました。

この時点で非推奨のメッセージが出たり、非推奨のオプション を使っていなければOKです。 出ている場合は、それぞれ内容に応じて修正をします。

コネヒトの場合、アップデートによって非推奨のメッセージが出たり、非推奨のオプションを使用している箇所はありませんでした。

2. プラグイン、ローダーを可能な限り最新に上げる

Webpack で使用しているプラグインやローダーがある場合は、可能な限り最新にアップデートします。 「可能な限り」と書いたのは、最新バージョンだと Webpack4 では使えない場合があるためです。

コネヒトの場合、2つのプラグインで引っかかりました。

まず mini-css-extract-plugin というプラグインの場合、v2 系は Webpack5 のみのサポート になったので、この段階では v1 系の最新にアップデートしました。

mini-css-extract-plugin v2.0.0 breaking changes

また optimize-css-assets-webpack-plugin というプラグインの場合、Webpack5 では css-minimizer-webpack-plugin を使うようにと書かれていたので、この時点では変更を控え、Webpack5 にアップデート後にライブラリを差し替えました。

optimize-css-assets-webpack-plugin README

このようにライブラリによって、それぞれのライブラリのドキュメントを見て判断する必要があります。 ただ数も多いので、ライブラリを一つずつバージョンアップしてビルドが通るかどうかを見て、エラーが出た場合はドキュメントなどを確認する、という感じで進めました。

3. Webpack5 にアップデート

いよいよ Webpack5 にアップデートします。

コネヒトの場合、アップデートしてみるとビルドエラーが出ました。 ただエラーメッセージを見たところプラグイン関連のエラーだったので、次のプラグイン、ローダーを最新に上げる対応に進みました。

4. プラグイン、ローダーを最新に上げる

2. で Webpack5 でないと使えなかったプラグインやローダーの最新版にアップデートしました。

5. 動作確認

正常に動作し、CIでのテストなどチェックが完了したら、最後に実機で動作テストします。

コネヒトの場合、この段階では特に問題は出ませんでした。

余談ですが、以前 「ママリの WebView を JavaScript + Flow から TypeScript に移行しました - コネヒト開発者ブログ」 の際に Cypress によるスクリーンショットの比較テストを導入していたので、明らかに動作しない場合などは作業途中でも自動で検出できました。 特定のページだけライブラリの関係で表示されないケースもあり、コミットしてプッシュするたびに自動でチェックしてくれるので効率が良かったです。

おわりに

既に Webpack5 がリリースされて1年以上経っているためか、関連ライブラリ含めドキュメントが充実していたので、比較的簡単にアップデートができました。

また自動テストを整備したおかげで効率よく作業を進められ、テストの重要性も感じました。

今後もママリのモダン化を進めていきたいです。

PR

コネヒトでは、フロントエンド開発のモダン化に挑戦したいエンジニアも募集中です!

hrmos.co

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(後編)

コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしたお話の後編にあたります。

前回までのお話はこちらをご参照ください。

今回は中編で定義したprotocol ApiServiceに準拠したテストを書いていきます。 前提としてNimble, Quickを使ってユニットテストを行っているのでNimble, Quickを使ったテストの書き方になっていますのでご了承ください。

おさらい

まずはApiServiceがどんなProtocolだったかおさらいしておきます。

protocol ApiService {
    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType
}

ApiServiceAPIRequestTypeに準拠した型を引数にもつcall(from Request)という関数が、返り値としてAnyPublisher<Request.Response, APIServiceError>を返すことを制約としていました。 APIRequestTypeについては 前編を参考にしてください。

APIと通信する際はエンドポイント毎にAPIRequestTypeに準拠した構造体を用意し、実装したApiServicecall(from:)にてURLRequestを作成してdataTaskPublisher(for: urlRequest)を叩くことでレスポンスを受け取っていました。テストを書く際もこのプロトコルに準拠させることで可読性の高いテストを書いていきたいと思います。

HTTPリクエストのテスト

まずHTTPリクエストのテストについて考えます。

リクエストの成功、失敗

HTTPリクエスト自体をテストしたいケースとしては以下のようなものが考えられます。

  • HTTPリクエストがなんらかの理由で失敗した場合、レスポンスとして返ってきた情報を正しく操作できているか確認したい。
  • HTTPリクエストが成功した場合、パース処理にdataが渡っていることを確認したい。

パース処理の確認

正常にレスポンスが渡ってきた場合でも、パースに失敗した場合クライアント側にデータを渡す訳にはいかないので、こちらも正しくエラーハンドリングできているか確認したいので、パース結果が適切かもテストしていきたいと思います。

どのようにテストを書きたいか

どんなテストが書きたいかを再確認したところで、どのように書きたいかを考えていきます。 当初自分の頭の中にあったイメージはこのようなものでした。

シナリオ

  1. レスポンス対象となるjson文字列を作成
  2. リクエストを作成
  3. 作成したデータをアダプターみたいなもので注入
  4. 注入したデータがパースされていてテストが通る

もう少し具体的にコードを交えて書くとこのような感じです。

describe("AppleAttach") {
    it("AppleID連携できる") {

        let data = try? JSONSerialization.data(
            withJSONObject: [
                "user_linked_accounts": [
                    "apple": true,
                ]
            ],
            options: []
        ) // 1. json文字列に変換

        let request = UserLinkedAccountsAttachRequest.AppleAttach(
            authorizationCode: "123"
        ) // 2. リクエスト作成

        3. 作成したデータをアダプターみたいなもので注入
        // apiService.adapter = dataみたいな感じ

        waitUntil(timeout: .milliseconds(100)) { done in
            apiService.call(from: request)
                .sink { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(_):
                        fail()
                    }
                } receiveValue: { response in
                    expect(response.userLinkedAccounts.apple).to(beTrue())
                    done()
                }
                .store(in: &cancellables)
        } // 4. 注入したデータがパースされてuserLinkedAccountsになっていてテストが通る
    }
}

3作成したデータをアダプターみたいなもので注入をどのように実現するのかイマイチよく分かっていなかったのでどうしようかなと調べていたら、URLProtocolを使用して、サーバーの応答を直接モックすることができることが分かりURLProtocolにモックする方法で書くことにしました。

URLProtocolとは

Appleのリファレンスには

An abstract class that handles the loading of protocol-specific URL data.

プロトコル固有のURLデータのロードを処理する抽象クラスとあります。

abstract class? それならprotocolでいいのでは?と思いつつマニュアルどおりにmockURLProtocolを実装するとこのような感じになりました。詳しくは wwdc2018 | Testing Tips & Tricks をご確認ください。ほぼ同じ実装です。

class MockURLProtocol: URLProtocol {

    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {

        guard let handler = MockURLProtocol.requestHandler else {
            assertionFailure("Received unexpected request with no handler set")
            return
        }

        do {
            let (response, data) = try handler(request)
            guard let data = data else {
                assertionFailure("Unexpected Null value given")
                return
            }
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        // 何もしないが上書きする必要がある
    }

}

requestHandler は、後でカスタムサーバーの応答を渡します。

下記4つの関数はoverrideする必要があります

  • canInit(with request: URLRequest) -> Bool
  • canonicalRequest(for request: URLRequest) -> URLRequest
  • stopLoading()
  • startLoading()

MockURLProtocolを指定したURLSessionを作成する

MockURLProtocolを使うためにURLProtocolを指定する必要があるので実装を追加していきます。

final class MockAPIService {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }
}

URLSessionConfiguration.ephemeralはキャッシュ、Cookie、またはクレデンシャルに永続ストレージを使用しないセッション構成となります。標準は.default これで、URLsessionMockURLProtocolを指定してMockURLProtocolを使用する準備が出来ました。

requestHandlerを使ったデータ注入

MockURLProtocolに定義したrequestHandlerを使ってMockURLProtocolにダミーデータを送る準備をしていきます。 まず、MockURLProtocolにデータを注入できることをprotocolを使って表現します。 ここが自分が分かっていなかった3のデータの注入の部分です。

protocol DataInjectable {
    var urlSession: URLSession { get }

    func injectingToMockURLProtocol(using data: Data?)
}

URLSession型のurlSessionというオブジェクトとinjectingToMockURLProtocol(data:)という関数を持つprotocolを定義しました。 続いてinjectingMockURLProtocolをextensionを使って標準実装します。

extension DataInjectable {
    func injectingToMockURLProtocol(using data: Data?) {
        MockURLProtocol.requestHandler = { request in
            return (HTTPURLResponse(), data)
        }
    }
}

injectingToMockURLProtocol(data:)は外からデータを受け取ってMockURLProtocol.requestHandlerにデータを渡す仕事を目的とします。 これでDataInjectableの実装が終わったので、MockAPIServiceDataInjectableに準拠させます。

こうなりました。

final class MockAPIService: DataInjectable {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }
}

標準実装しているのでただ準拠させているだけです。ただこのままだとMockAPIServiceはリクエストできないのでprotocol ApiServiceにも準拠させます。

final class MockAPIService: ApiService, DataInjectable {

    var urlSession: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        urlSession = URLSession(configuration: configuration)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType {
           // なにかかく
    }
}

このままだとfunc call<Request>(from request: Request)の返り値がないので中身を実装していきます。といっても中編で定義した処理内容とほとんどかわりません。

let request: URLRequest = request.buildRequest()
let decorder = JSONDecoder()
decorder.dateDecodingStrategy = .iso8601
decorder.keyDecodingStrategy = .convertFromSnakeCase

return urlSession.dataTaskPublisher(for: request)
    .tryMap { (data, response) -> Data in
        if let urlResponse = response as? HTTPURLResponse {
            switch urlResponse.statusCode {
            case 200..<300:
                return data
            default:
                throw try decorder.decode(ApiErrorResponse.self, from: data)
            }
        }
        return data
    }
    .mapError { error in
        APIServiceError.responseError(error)
    }
    .flatMap {
        Just($0)
            .decode(type: Request.Response.self, decoder: decorder)
            .mapError { error in
                return APIServiceError.parseError(error)
            }
    }
    .eraseToAnyPublisher()

これでURLProtocolにデータを注入したテストがかけるようになりました。 テストの全体的な流れはこのようになります。

class UserLinkedAccountsAttachRequestSpec: QuickSpec {

    override func spec() {
        let apiService = MockAPIService()
        var cancellables: [AnyCancellable] = []

        beforeSuite {
            Nimble.AsyncDefaults.timeout = TestConstants.timeout
            Nimble.AsyncDefaults.pollInterval = TestConstants.pollInterval
        }

        beforeEach {
            cancellables = []
        }

        describe("AppleAttach") {
            it("AppleID連携できる") {
                let data = try? JSONSerialization.data(
                    withJSONObject: [
                        "user_linked_accounts": [
                            "au": false,
                            "apple": true,
                            "google": false,
                        ]
                    ],
                    options: []
                ) // 1. json文字列に変換

                let request = UserLinkedAccountsAttachRequest.AppleAttach(
                    authorizationCode: "123"
                ) // 2. リクエスト作成

                apiService.injectingToMockURLProtocol(using: data)
                // 3. 作成したデータをアダプターみたいなもので注入

                waitUntil(timeout: .milliseconds(100)) { done in
                    apiService.call(from: request)
                        .sink { completion in
                            switch completion {
                            case .finished:
                                break
                            case .failure(_):
                                fail()
                            }
                        } receiveValue: { response in
                            expect(response.userLinkedAccounts.apple).to(beTrue())
                            done()
                        }
                        .store(in: &cancellables)
                }  // 4. 注入したデータがパースされてuserLinkedAccountsになっていてテストが通る
            }
        }
    }
}

HTTPリクエストのテストはこれでうまく行きそうです。

ViewModelのテスト

ViewModelのテストも先程実装したMockAPIServiceでいけるかなと自問自答したときに、ViewModelの関心はAPIの処理が適切にハンドリングされていることではないはずで、URLProtocolを使ってモックするやりかたではないなと思いました。

そのため、MockAPIServiceとは別のサービスクラスを用意しました。先程のサービスクラスはDataInjectableMockAPIServiceに改名しました。

ViewModelのテストシナリオ

テストシナリオを満たす前にMVVMパターンのこれらを満たしている必要があります

  1. 初期化時にViewModelにAPIを渡すことができる(Dependency Injection)
  2. ViewModelの外から渡された値を元にプロパティを変更できる(ViewModelのInput)
  3. ViewModelの外から監視しているプロパティが内部ロジックを経て購読できる(ViewModelのOutput)

以上を踏まえた上で今回のケースではAppleIDとの連携について考えたいと思います。

このようなアカウントの連携画面があって、AppleIDがすでに連携済みである場合、ViewModel内のisAppleLoggedInResultというプロパティがtrueになっていることをテストしていきます。 f:id:ohayoukenchan:20211228000821p:plain

シナリオはこのような感じです

  1. ViewModelを初期化するタイミングでisAppleLoggedInResulttrueになっている

これをテストしていきます。

通信の結果を置き換える

前述の通りViewModelのテストはAPIの通信結果のハンドリングには関心を持たせたくないので通信結果を準備したデータに置き換えていきます。 実際に通信するわけではなく通信結果の代わりにMockAPIServiceに置き換えたデータを渡してそれを返してあげれば良さそうです。

MockAPIServiceにstub(type: Request.Type, response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)) where Request: APIRequestTypeを定義してArrayに追加できるようにします。ここではAnyPublisher<Request.Response, APIServiceError>なAnyPublisherが追加される想定です。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }
}

ApiServicecall(from Request)を実装しなければいけないので追加します。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {
        // ここになにかかく
    }
}

このMockAPIServicecall(request:)されたときに追加したstubがAnyPublisher<Request.Response, APIServiceError>で返ってくるようにcall(request:)の中身を実装していきます。 最終的には下記のようになりました。

final class MockAPIService: ApiService {
    var stubs: [Any] = []

    func stub<Request>(
        for type: Request.Type,
        response: @escaping ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
    ) where Request: APIRequestType {
        stubs.append(response)
    }

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {

        let response =
            stubs.compactMap { stub -> AnyPublisher<Request.Response, APIServiceError>? in
                let stub = stub as? ((Request) -> AnyPublisher<Request.Response, APIServiceError>)
                return stub?(request)
            }
            .last

        return response
            ?? Empty<Request.Response, APIServiceError>()
            .eraseToAnyPublisher()
    } // Arrayに登録されている最後のデータを返す。もしくは空のPublisherを返す
}

MockAPIServiceを利用する

最終的にテストケースはこのようになりました。

final class ProviderLoginSettingViewModelSpec: QuickSpec {
    override func spec() {
        var apiService = MockAPIService()
        var cancellables: [AnyCancellable] = []

        // outputs
        var isAppleLoggedInResult: [Bool] = []

        beforeEach {
            apiService = MockAPIService()
            cancellables = []

            isAppleLoggedInResult = []
        }

        func bindVM(_ vm: ProviderLoginSettingViewModel) {
            vm.$isAppleLoggedIn
                .sink {
                    isAppleLoggedInResult.append($0)
                }
                .store(in: &cancellables)
        }

        describe("init") {

            context("apple id連携済みのとき") {
                it("isAppleLoggedInが設定される") {

                    apiService.stub(for: UserLinkedAccountsAttachRequest.Me.self) { _ in
                        Record<UserLinkedAccountsResponse, APIServiceError> {
                            promise in
                            promise.receive(
                                UserLinkedAccountsResponse(
                                    userLinkedAccounts: UserLinkedAccounts(
                                        au: false,
                                        apple: true,
                                        google: false
                                    )
                                )
                            )
                        }
                        .eraseToAnyPublisher()
                    } // 通信結果を置き換える

                    let vm = ProviderLoginSettingViewModel(
                        referrer: nil,
                        apiService: apiService 
                    ) // ViewModelの初期化時にMockAPIServiceをDependency Injectionする

                    bindVM(vm) // viewModelのbinding

                    expect(isAppleLoggedInResult).to(equal([true]))
                }
            }
        }
    }
}

expect(isAppleLoggedInResult).to(equal([true]))UserLinkedAccountsResponseの値に置き換えられていることが分かります。 これでViewModelのテストもうまくかけそうです。

最後に

今回はAPIのテストとViewModelのテストを書く部分についてお伝えしました。 Combine使ってみたいけどテストどうやって書くのかな?とかAPIのテストの書き方など少しでもお役に立てたら幸いです。

URLProtocolのモックやAPIクライアント自作によるURLRequestの生成などで普段エンドポイント作成しているだけでは分からないことが少し分かった気になりました。 余談ですが、初実装ではbodyパラメータの実装がまんま抜けてて、後で気づいて冷や汗を書きました。

ママリはこれからもSwiftUIやCombine含め新たな挑戦をどんどんしていくので、興味を持っていただけたら是非こちらからエントリーいただくまでご連絡ください!お待ちしております。

Android版ママリアプリのリファクタ事情 ~ ViewState編 ~

こんにちは。2017年11月にAndroidエンジニアとしてjoinした@katsutomuです。

昨年のエントリーで緑髪 -> 赤髪 -> 金髪と定期的なアップデートをご報告いたしましたが、今は髪が伸びて、3分の2が黒髪になってきています。

さて今回は、Android版ママリアプリのリファクタリングの事情の第二弾として、ViewState *1 導入提案時のお話を共有します。

導入背景

ママリアプリの実装は、ViewModelにUI要素ごとの状態をLiveDataで持ち、状態管理を行っています。この場合、UI要素が増えるたびにViewModeにLiveDataが増やすことになるため、UIの状態管理の難易度が高い状態になっていました。

この解決策としてViewの状態を1つにまとめたViewStateの導入方針を提案したところ、LiveEventの扱いをどうするか?が主な議題になりました。

なぜLiveEventが必要か?

まず、LiveEventが必要とされている理由についておさらいしておきます。

github.com

ママリアプリではViewModelとViewの間のやりとりの中で、一度だけ実行したいイベントの場合、LiveEventで実装をしています。例えば、以下のようなユースケースです。

  • メッセージ表示
    • 自動的で消えるトースト表示
    • 操作が伴う入力フォームやエラーダイアログ表示
  • 画面ナビゲーション
    • 質問投稿後の画面移動など

この場合LiveDataで実装すると、困ったことがおきえます。LiveDataが常に最新の状態を保持し、Activityがバックグラウンドからフォアグラウンドに戻った場合にも、最新の状態を受け取る性質を持つため、一度きり表示したいメッセージが再び表示される実行済みの画面ナビゲーションが再実行されるといったことが起きてしまいます。

これらの解決策として、一度だけイベントが送信されることを保証するLiveEventを利用しています。

導入方針の検討

今回はこのLiveEventが必要な事情を考慮に入れ、ViewStateの導入後の設計を2パターン比較して、方針を相談しました。

1. 状態とイベントを区別するパターン

f:id:katsutomu0124:20220204153730p:plain 社内で上がってきたアイディアの一つです。ViewModel → Viewに反映するべき状態をViewStateで保持し、一度きりのイベントをViewCommandで伝えます。

pros

  • UIの状態とイベントで使い分けができる。特に画面ナビゲーションはその方が直感的。

cons

  • Viewに影響を与える要素が複数になり、複雑度が増す

2. 状態とイベントを一緒に扱うパターン

f:id:katsutomu0124:20220204153809p:plain

最近アップデートされた、Googleのアーキテクチャガイドラインでのアプローチです。ViewModel → Viewに反映する状態や画面遷移のトリガーは全てViewStateで持ちます。

pros

  • Viewに影響を与える要素が一つになり、複雑度が下がる

cons

上記の2つのパターンで比較し、Googleのアーキテクチャガイドに従うことや、Viewに影響を与える要素を一つにすることが、今後はメリットが大きいと判断し、状態とイベントを一緒に扱うパターンを選択することにしました。

その後、想定される3つのユースケースの具体的な実装方法をつめて、結果的に以下のルールを選定しました。

1. 自動で消えるメッセージ表示

ViewStateに表示メッセージを持たせる。表示したら表示完了をViewModelに伝える。

2.ユーザーの操作を伴う入力ダイアログやエラーダイアログ表示。

ViewStateに表示メッセージを持たせる。重複表示はUI側で制御し、再表示が不要になったらViewModelに伝える。

3.画面ナビゲーション

ViewStateに入れることが違和感を感じるため、別のイベントとして扱う。

結果的に、Googleのアーキテクチャガイドのパターンをベースにしつつも、現時点では画面ナビゲーションはViewStateと分けて管理する方が、コネヒトのAndroidチームでは違和感が少ないと判断し、このルールを選定しました。今後はこのルールをベースにリファクタリングを進めていく予定です。

f:id:katsutomu0124:20220204153842p:plain

参考までに、相談時の議事録を共有しておきます。

議事録メモ

- 自動的に消えるようなトーストメッセージ表示
    - ViewStateのみで扱う違和感ない
- ユーザーの操作を伴う入力ダイアログやエラーダイアログ表示
    - ViewStateのみで扱う場合、少し違和感
    - 表示してすぐにonShown呼び出す。
        - UI側で表示制御をするのはあり
            1. ViewModelから表示したいメッセージを送る
                - uistateに表示したいメッセージが入る
            2. Viewはメッセージを受け取ってダイアログを表示する。
                - View側で、多重表示しない制御を入れる
            3. ダイアログ非表示
                - ユーザーが操作してダイアログを非表示にする
                    - viewModel.onShown()
                    - uiStateから表示したいメッセージ消す
                - ViewModelから消したいパターン
                    - uiStateから表示したいメッセージ消す
        - DialogFragment:tagで表示確認した上で表示する。
        - AlertDialog:UI側でフラグ管理する?ちょっと冗長。そもそも今回の場合はAlertDialogじゃなく、Toastや入力フォームエラー表示するのがベターかも?
            - Alert Dialogは単体で使わないほうが良い認識。
            - DialogFragmentに内包して使うとリーク問題が解決する。
        `そもそもダイアログは本当に必要な場合だけ使うように限定したい`
- 画面ナビゲーション
    - ViewStateの方法だと違和感あるかも。これだけViewCommandで扱うのはあり。
        - その場合は名前を変えた方が良さそう
        - 将来的にはJetpack Navigationで実装したいが・・・。
    - ActivityからActivityに切り替えるパターン
        - ViewCommandの名前をNavigationCommandにしてワンショットでイベントを送る
    - 1Activityで複数Fragmentを切り替えるパターン
        - こちらはViewStateにシーンの概念を持たせるのが良さそう

提案前にやっていること

筆者は、新しい設計や機能を試すときに、弊社のGitHub上にAndroidのコードを実験するシンプルな構成のAndroidプロジェクトを用意し、一度実験した上で、提案を進めるようにしています。

せっかくなので、今回のViewState導入の提案前に実験したサンプルコードを抜粋して紹介します。

サンプルコード

// 画面全体のUIの状態を表したクラス。
data class MamariUiState (
    val contents: List<ListViewItem> = listOf(),
    val errorMessage: List<ErrorMessage> = listOf()
)
class MamariViewModel: ViewModel() {
    // メッセージの内容をViewStateで
    private val _viewState = MutableStateFlow(MamariUiState(
        errorMessage = listOf(
            ErrorMessage(
                UUID.randomUUID().mostSignificantBits,
                "errorだよ"
            )
        )
    ))
        val viewState = _viewState.asStateFlow()

        // 表示されたらメッセージを破棄する。
    fun onErrorShown(errorId: Long) {
        _viewState.update { current ->
            val newErrorMessage = current.errorMessage.filterNot { it.id == errorId }
            current.copy(
                errorMessage = newErrorMessage
            )
        }
    }       
}

終わりに

今回はリファクタリングの2歩目を紹介いたしました。最終的なアーキテクチャは過去の記事で触れていますので、是非そちらもご一読ください!

tech.connehito.com

PR

コネヒトでは、バリバリとリファクタリングを進めてくれるAndroidエンジニアを募集中です!

hrmos.co

*1:UIStateと呼ばれることも多いですが、弊社ではViewModelと近しい存在と捉えて名前も寄せています

リモートワーク下でのデイリースクラム運営tips

こんにちは! @takoba_ です。

最近は ダウ90000 という8人組の演劇・コントユニットにハマっています。ホント大変だったよ〜〜*1

リモートワークでのプロダクト開発、難易度上がってない??

アジャイル開発における(現時点での)筆頭とも言えるフレームワークである「 スクラム(Scrum) 」ですが、わざわざ “筆頭” と呼ぶくらいに多くのプロダクト開発現場で導入されている手法になりつつあります。

一方で、直近のスクラムに関するアップデートといえば、 2020年に原典となるスクラムガイドが改訂される というイベントはあったものの、世界的に発生している 新型コロナウィルス(COVID-19)感染症のパンデミック によってリモートワークが新常識となった2022年現在において、リモートワークを前提としたスクラムに関連する模索が各所のプロダクト開発現場で続いているものだと思われます。

一般的に、リモートワークへの移行によって同期的なコミュニケーション(ここでは会話などを指す)が自然と減ってしまうことにより、スピード感の欠如や共通認識の齟齬が発生するなどの影響が発生しているものと思われます。*2

要は、スクラムを採用している如何に関わらず、 リモートワークに移行したことで全体的にプロダクト開発という仕事の難易度は上がっている と思われます。

ちょっとでもスクラムで円滑なプロダクト開発に貢献したいぞ

なので、上記のような状況を踏まえつつも、スクラムを採用することでコミュニケーションロスが減ってプロダクト開発が円滑になればな〜〜〜と個人的には思っていたりします。(実はそんなに問題視していない成熟したスクラムチームもあるかもしれませんが)

というわけで、今回はコネヒト社の @takoba_ が所属するチームにおいて、リモートワークに移行したことで発生した、スクラムの各種機能に関する工夫をお届けしようかな!と思い立って、筆をとった次第でございます。

[PR] コネヒト社でのリモートワークに関する発信まとめ

ちなみに、過去に @dachi_023 が書いたリモートワークに関するエントリがあるので、こちらもよかったらご覧ください〜〜

tech.connehito.com

tech.connehito.com

リモートワーク下におけるデイリースクラムの運営tips

今回は、デイリースクラムがどのように変化したか、をお伝えできればよいかな!と思います。それでは行ってみましょ〜〜〜🙋‍♀️

基本、司会の人がディスプレイを画面共有する

だいたいカンバン(弊チームだと ZenHub 使ってます)を開いて、画面を共有しておきます。

まあ、司会者の手元を共有してなくても ZenHub とか GitHub Project とかだったら URL さえ共有しておけば同じものが同期的に見れるんですが、 Zoom などで画面を共有することは「(同じものを見てるぞ!という感覚で) Zoom に参加しているメンバーのマインドシェアをジャックする」的な感覚が発生してそうで、画面共有することを薦めてます。まあ注視させるというかなんというか。このあと話す ”デイリースクラムでのメモを残す” ことでも話しますが、ホワイトボードの前にみんなが集まる、みたいなイメージを残したい意図もあります。

式次第のメモを書いておいて、それの通りに進めて、やる人によるブレを減らしておく

司会の人の進行力によって、デイリースクラムの品質が下がらないように、進行台本(個人的には式次第と呼んでる)を用意して、それをベースに進行するようにしています。

ここでは、特に公開しても問題なさそうだったので、弊チームで用いている式次第を公開します。

  1. Slack 上にメモを書く準備
    • いまのところ Slack 上にブロードキャストするようにしてるので、直接 Slack に書いてる
  2. カンバン見ながら各開発者から1-2分程度で共有
    • 前回のデイリースクラムからやったこと
    • このあとやろうとしてること
    • 困ってることとか相談とか
  3. スプリントゴールの達成度合いを確認
    • もし、今回のスプリントで達成できなさそうなゴールがあれば、スコープの調整/差し戻しを行う
    • スプリントゴールのいけそう感を各開発者に尋ねたりする
  4. 前回のスプリントレトロスペクティブで決めた Try の進捗を確認する
    • Try は各人にアサインされてたりする(必要に応じて issue を切るけど、あえて緩めに管理してる)
  5. 休暇をとってるメンバーがいたら、その人へのフォローを行う
    • お休みのメンバーにアサインされてる issue があれば、状況を整理してアサインし直す
      • できるかぎりアサインされてる issue がない状態でお休みをとってもらいたいけど、そうじゃない場合もあるから
  6. 最後にその他の連絡/共有を聞く
    • 直近のお休み予定とか
    • mtg の時間調整とか
    • 何か告知事項
    • スプリントの最終日に近づいてきたら、スプリントレビューでデモするスプリントゴールを選んだりする

Slack でチームのチャンネルを開いておいて、メモをとる

これは、現時点では @takoba_ が推し進めてて式次第にはガッツリ入れてないんですが*3、なんとなくデイリースクラムのサマリーを残しておいた方が、状況や雰囲気が Slack のチャンネルを通してチームメンバーやその他不特定多数に伝わる気がしてて、やってます。

また、リアルタイムにメモを書き始めると「あれ?これってどうなってたっけ?」「あっ、このタスク漏れてないすか??」みたいな疑問が自然と現れてくるんですよね...なんでなのかわからないんですが、めちゃ便利です。

コミュニケーション構造としてホワイトボードが寄与していたもの

f:id:plane25:20220122170223j:plain
こういうオフラインのディスカッションが懐かしいわね...( ˘ω˘)

んーこれなんでなんだろな... Zoom ってオフラインで車座(円形)になってスタンディングしてるのとなんか違う気がしていて、コミュニケーションが P2P みたいな1対1の構造になってて、情報が情報が内にとどまってしまうというか、外化されにくい気がします。

なので、ホワイトボードに書き出すが如く、 Slack の投稿画面を見せながらメモを書くことで、 ホワイトボード(=外化された情報)を見ながら会話する構造に変化できる 気がします。

なお、この話は科学的根拠はなく、自説に基づいた仮説なんですが、 ワークショップ設計の文脈で「壁に貼られたポストイットを眺めながら(対面せずに)肩を並べて複数人と会話する、みたいな構造にすることで心理的安全性が発生しやすい」みたいな経験則 はあります。ワークショップを運用するときに、そういう会話構造になるような立ち位置を設計することが大事だよ〜〜というかんじです。*4

おわりに

とりあえず3つほど紹介しましたが、いかがでしたか?参考になりそうなものはあれば幸いです🙏

新常識となったリモートワークにきっちり順応して、よりよいプロダクト開発に邁進できるようにやっていき!💪

参考

*1: ダウ90000 のコント「バーカウンター」より

*2:特に根拠となる論説はないのですが、肌感として

*3:お休みの人がいたら必ず書くようにはしてるけど

*4:これ当時アイエムジェイ(現アクセンチュア)に在籍されていた太田文明さんに指摘されて「なるほど確かに〜〜〜」となった話だったんだけど、 @fortkle に「これ"問題 vs 私たち"の構図だよね〜」って教えてもらってスッキリしました!ありがと〜〜

Tableauのパラメーターを横並びのラジオボタンにする拡張機能をつくった

こんにちは!コネヒトのエンジニアあぼです。この記事はコネヒト Advent Calendar 2021の24日目です。

今回は、コネヒトでデータ分析や新規プロダクトに使われているTableauの拡張機能をつくったので紹介します。

つくったもの

github.com

この拡張機能は、ダッシュボード上から参照できるパラメーターを1つ、横並びのラジオボタンとして表示するシンプルな拡張です。

「構成」からダイアログを開くと、ダッシュボードから参照できるパラメーターのうち許容値がリストであるものが表示されるので、この中から横並びのラジオボタンにしたいパラメーターを選択します。すると、パラメーターを画像のように横並びで表示することができます。

f:id:aboy_perry:20211223171641p:plain

ちなみにダッシュボード上に拡張機能オブジェクトを複数設置すれば、複数のパラメーターにも対応できます。.trexファイルをダウンロードすれば誰でも使えるので、ぜひ使ってみてください! 💪

つくったきっかけ

業務で必要になったのがきっかけです。Tableauでは許容値がリストであるパラメーターはラジオボタンのようなUI(単一の値のリスト)で表示することができますが、横並びにするような設定がありません。

f:id:aboy_perry:20211223171813p:plain
許容値がリストであるパラメーター。この場合Order ID, Order Date, Customer Nameの3種類の文字列を取りうる。

ダッシュボードのスペースやUI設計の都合上、横並びにしたかったため方法を探していました。Tableauコミュニティでも同様の相談が複数見受けられました。できないからといってクリティカルではないものの、どうにかできないかな〜と考えていました。

こういった公式がまだ機能として提供していないものについては、Tableauのコミュニティでtipsが共有されたりしています。今回のラジオボタンの横並びについてもコミュニティでtipsを見つけることができました。具体的にはこちらのYouTubeの動画や、こちらのナレッジベースのように、ワークシートやダッシュボードアクションを駆使することで横並びのラジオボタンをつくることができるというもので、これはこれで素晴らしいのですが、

  • 多少手間がかかる
  • リストの値を取るデータフィールドがないようなデータ構造だとできない

という問題がありました。前者はしょうがないとしても、今回のきっかけとなったデータ構造は後者に該当するためこの方法ではできませんでした。後者は例えば動画内で出てきた計算フィールドSTR([Region Parameter] = [Region]) + [Region]において、RegionRegion Parameterで定義したリスト内の値をとるデータでなければいけません。Region ParamerterEast, West, Central, Southの4つの文字列を許容値とするリストならば、その4つの文字列を取りうるRegionというデータフィールド(列)が必要ということです。

f:id:aboy_perry:20211223172038p:plain
Tableau付属のデータセット「Superstore」のRegionのようなデータ構造なら可能

そこで、Tableauが提供しているもう一つの選択肢、拡張機能を使うことにしました。ユーザーはTableauのギャラリー(Tableau公認*1)や、ユーザーコミュニティポータルで公開されている拡張機能を探してダッシュボードに組み込めるほか、Tableau Extension APIを活用して自作することもできます。

今回はニーズに合う拡張機能が見つからなかったので自作しました。拡張機能をつくるには、Tableauの知識、Tableau Extension APIの知識、多少のWebアプリケーション開発の知識が必要になります。今回のようなシンプルな拡張機能であれば比較的簡単につくることができました。

以降では、今回の拡張機能をつくるうえで出てきた実装をいくつか紹介します。

実装

Tableau拡張のつくり方の詳細は公式のドキュメントにまとまっていますのでご覧になってみてください。今回JavaScriptのライブラリは、公式サンプルにも登場するjQueryを利用しました。

ダッシュボード内のパラメーターを取得する

ダッシュボード内のパラメーターはdashboardContent.dashboard.getParametersAsync()で取得できます。その中から、許容値がリストであるパラメーターに絞り、inputやlabelを作り反映させています。

tableau.extensions.dashboardContent.dashboard.getParametersAsync().then((params) => {
    params
        .filter((p) => p.allowableValues.type === tableau.ParameterValueType.List)
        .forEach((p) => {
            const hElement = $('<h3>')

            $('<input />', {
                type: 'radio',
                id: p.name,
                name: 'HorizontalRadioButton',
                value: p.name,
            }).appendTo(hElement)

            $('<label>', {
                for: p.name,
                text: p.name,
            }).appendTo(hElement)

            $('#parameters').append(hElement)  
        )      
})

パラメーターとラジオボタンを同期させる

拡張機能側のラジオボタンを押したらパラメーターの値も変わるようにする部分です。また、Tableauはダッシュボードアクションなどでパラメーターに作用できるので、ダッシュボード側の操作によってパラメーターが変わったことを拡張機能側で検知して自身のラジオボタンに反映させられるように、つまり双方向に同期されるようにします。

拡張機能からパラメーターへの反映はparameter.changeValueAsync()で行い、パラメーターから拡張機能への反映はパラメーターへイベントリスナーを登録して行います。

tableau.extensions.dashboardContent.dashboard.getParametersAsync().then((parameters) => {
    const selectedParameter = parameters.find((p) => p.name === savedValue)
    // パラメーターの変更検知
    selectedParameter.addEventListener(tableau.TableauEventType.ParameterChanged, onParameterChange)

        const parameterValuesElement = $('<div id="parameter">')
        selectedParameter.allowableValues.allowableValues.forEach((dataValue) => {
        const eachValueElement = $('<div style="display: inline-block">')

        $('<input />', {
            type: 'radio',
            id: dataValue.value,
            name: selectedParameter.name,
            value: dataValue.formattedValue,
            checked: dataValue.value === selectedParameter.currentValue.value,
                        // 拡張機能 => パラメーター へ同期
            click: () => selectedParameter.changeValueAsync(dataValue.value),
        }).appendTo(eachValueElement)

        $('<label>', {
            for: dataValue.value,
            text: dataValue.formattedValue,
        }).appendTo(eachValueElement)

        parameterValuesElement.append(eachValueElement)
    })
    $('#parameter').replaceWith(parameterValuesElement)
})

// パラメーター => 拡張機能 へ同期
const onParameterChange = (parameterChangeEvent) => {
    parameterChangeEvent.getParameterAsync().then((p) => {
        $(`input:radio[value="${p.currentValue.formattedValue}"]`)
            .prop('checked', true)
    })
}

設定をワークブックに保存し永続化する

拡張機能の実態はホスティングされたWebサイトなので、ワークブックの再読み込みで初期化されます。Tableauの拡張機能は基本的に構成ダイアログから設定を変更できるように作りますが、その設定を保存するためのコードを書く必要があります。

設定はkey-value形式で保存でき、ワークブック単位で保持されます。構成ダイアログをひらく場合はtableau.extensions.ui.displayDialogAsync()、とじる場合は tableau.extensions.ui.closeDialog() も呼んであげます。どちらも引数にはpayloadを渡せますが、今回は設定の読み込みと書き込みは全てtableau.extensions.settings経由で行いたかったので、payloadは空文字でも問題ありません。*2

// 構成ダイアログをひらく
tableau.extensions.ui.displayDialogAsync('config.html', payload)

// 設定をセット
tableau.extensions.settings.set('key', value)
// 構成をワークブックに保存
tableau.extensions.settings.saveAsync().then(() => {
    // 構成ダイアログをとじる
    tableau.extensions.ui.closeDialog(value)
})

keyを指定してsettings.get()で保存された値を取得できるほか、イベントリスナーの登録によって設定が変更されたことを検知できます。拡張機能や構成ダイアログの初回読み込み時は settings.get()、以降はイベントリスナー経由で値を取得するという使い分けになります。

tableau.extensions.initializeAsync({'configure': configure}).then(() => {
    // 初回読み込み時はここで保存された値を取得
    savedValue = tableau.extensions.settings.get('key')
    // イベントリスナーを登録しておいて
    tableau.extensions.settings.addEventListener(tableau.TableauEventType.SettingsChanged, onSettingsChange)
})

const onSettingsChange =  (settingsEvent) => {
    // 最新の値を取得
    savedValue = settingsEvent.newSettings.key
    // UIの更新など
}

checkedのときにinputが表示されない

inputがcheckedのときに消えてしまう不具合がありました。下の画像では「Order Date」がcheckedなのですが、ラジオボタンの丸ポチが消えてしまっています。

f:id:aboy_perry:20211223172324p:plain

原因は特定できなかったため、結局CSSを別途当てることにしました。input[type=radio]に対してdisplay:noneを当てて、labelのbefore/afterに対してスタイルを当てて擬似的にラジオボタンのように見せて対応しました。

おわりに

拡張機能を上手く活用すれば、Tableau Extension APIとWebでできることはだいたいできるので、Tableauの可能性が広がりますね。拡張機能は開発するうえでTableauの理解も深まりますし、コミュニティにも貢献できるのでオススメです!