Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

redux-thunkを使っているプロジェクトでのAPIリクエストの競合をAbortControllerで素朴に解決する

こんにちは。ForSchool事業部の石上です。今年の抱負はラーメンを月2食に抑えることです。今の所はなんとか達成できております。

さて今回は、Studyplus for School(以下、社内での呼び方でFSと書きます)のフロントエンドで、どうやってAPIリクエストの競合を回避したかという話について書きます。

背景

FSのフロントエンドには、非同期の処理をするためにredux-thunkを使っています。

Reduxで非同期処理といえばredux-sagaとredux-thunkどっちを使うのか、というのがよく話題に上がると思います。FSでのredux-thunkの採用理由は単純で、使い方をすぐ理解できるからでした。結果としてactionに非同期処理が入ってくることによる苦しみを味わうことになったのですが、その話はまた別でしたいと思います(今回の話もその一部です)。

FSのフロントエンドはシングルページアプリケーション(以下、SPA)です。HTMLを毎回ダウンロードするのではなく、必要なデータを必要なときにAPIから取得して、画面の特定の部分を更新します。

そのため何も考えずに実装をすると、うっかり間違った画面を表示することになります。まずはその問題について簡単に、なるべく具体的な例で書いていきます。

検索状態に対して画面に表示される結果が合わなくなる可能性

FSには、生徒一覧を表示する画面があります。この画面はとても一般的な機能を持っていて、検索条件を指定すると画面が更新されて、それにマッチする生徒が表示されます。

SPAでなければ、検索条件のクエリパラメータをもとにSQLで生徒一覧を取得、それをHTMLに埋め込んで表示という流れになるかと思います。

SPAの場合は、検索条件のクエリパラメータをつけたAPIのURLへリクエストを投げ、その結果を画面に表示します。基本的にはSPAでない場合と変わりはないですね。

ただ、気をつける必要があるのはその結果の反映順序です。APIへのリクエストとレスポンスは、工夫をせずに行うと、リクエストした順番とは違う順番でレスポンスを処理する可能性があります。

今回の例で考えてみます。高校生のタグをつけられた生徒を取得するリクエストの直後に、中学生のタグをつけられた生徒を取得するリクエストをしたとします。工夫をせずにただリクエストを投げた場合、選択したタグは高校生なのに表示されるのは中学生の生徒一覧、ということが起きうるのです。

検索条件と結果がちぐはぐになってしまった場合のイメージ
検索条件と結果がちぐはぐになってしまった場合のイメージ

解決方法

うちの場合、AbortControllerというブラウザの機能を利用してこの問題を回避しています1。Abortとは中断という意味の英単語なので、中断制御するやつという感じですね。機能もまさにその名のとおりです。

使い方は簡単で、このMDNのリンクに書かれている例の通りです。これにコメントを書き加えると以下のような感じです。

// AbortControllerを生成
var controller = new AbortController();
var signal = controller.signal;
var downloadBtn = document.querySelector('.download');
var abortBtn = document.querySelector('.abort');
downloadBtn.addEventListener('click', fetchVideo);
// 中断ボタンをクリックすると
abortBtn.addEventListener('click', function() {
  // リクエストを中断する
  controller.abort();
  console.log('Download aborted');
});
function fetchVideo() {
  ...
  // fetchの引数にAbortControllerのsignalを指定
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = 'Download error: ' + e.message;
  })
}

これをredux-thunkの中で使うために、以下のような実装にしました。

  1. APIリクエストの処理を担当するクラスをつくる
  2. そのクラスに、AbortControllerも管理させる
  3. interruptGetというメソッドを生やして、そのメソッドでAPIを叩いたときは、競合するリクエストを中断してからリクエストを投げるようにする
  4. AbortErrorはキャッチして無視する(エラー表示などはしないようにしておく)
client.interruptGet('/api/hoge')
  .then(res => {
    dispatch(getHogeSuccess(res))
  })
  .catch(err => {
    if (err.name === 'AbortError') {
      return
    }
    dispatch(getHogeError(err))
  })

ライブラリを入れず素朴に実装したつもりが、初見の人にはやや実装がわかりにくくなってしまった感もあります。ただ、これを利用したリクエストの挙動をブラウザで見てみると、やっていることはわかりやすいはずです。以下は検索条件のタグを2つ指定している状態から、ががっと2回のクリックでタグを外した様子です。下に見えているのがChromeのNetworkタブで、ここに発生したAPIリクエストが表示されています。

1回目のクリックで生徒APIへのリクエストを投げようとしますが、すぐ次のクリックによってそれが中断され、Statusがcanceledになっています。上記したinterruptGetでAPIを叩くと、必ずそれ以前の同APIへのリクエストをキャンセルするようになっているため、画面に反映されるのは最後に投げたリクエストのレスポンスとなります。

その他のアプローチ

以下のようなアプローチもあるかと思います。

thunkの中で状態を見て、1つ前のリクエストの処理が完全に終わるまで次のリクエストを投げないようにする

このやり方は、Redux作者のDan Abramov氏のスクリーンキャストで紹介されています。

実はこのスクリーンキャストはこの記事を書いているときに知りまして、観てみたらAbortControllerをつかった実装よりもよさそうだと感じました。今回紹介した画面を今後リファクタリングする際には、画面のstateの正規化をした上でこの方法を採用したいと思います。

takeLatestという関数があるらしい

redux-sagaを使っているのであれば、これが使えそうです。FSではredux-thunkを利用しているのですが、この問題のためにredux-sagaへ乗り換えるということはしませんでした。

まとめ

素朴にやってみたものの、thunk側でAbortErrorをキャッチして無視しないといけない不便さもあります。今後はそういった約束事を意識しないでも、正しい状態を保てるようなつくりへとリファクタリングしていくことが必要だと感じています。


  1. 対応ブラウザによって、polyfillが必要です。

WorkManager とViewModelの間でデータを受け渡しした話

こんにちは、モバイルクライアントグループの中島です。

今回はWorkManagerを使った非同期処理で、WorkManagerとViewModelの間でデータのやりとりを行なう方法について話したいと思います。

ここで「データのやりとり」と言っているのは、WorkManagerに処理をリクエストするViewModelとWorkManager内で実際に処理を行なうWorkerクラス間のデータ受け渡しを指します。

なお、執筆時に利用している WorkManager のバージョンは2.3.0です。

Studyplus AndroidアプリにおけるWorkManagerの導入については、下記をご参照ください。

tech.studyplus.co.jp

やりたいこと

具体的にやりたいことはこんな感じです。

  • TODO1: ViewModel -> Worker

    • Workerで行なう処理のためにパラメータを渡したい
  • TODO2: Worker -> ViewModel

    • Workerで行なった処理の結果をViewModelに返したい
class MyViewModel @Inject constructor(
    private val workManager: WorkManager
) : ViewModel() {

    fun request(data: String) {
        // TODO 1: ここでWorkerにデータを渡したい
        val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>().build()
        workManager.enqueue(request)
        // TODO 2: ここでWorkerの実行結果を処理したい
    }
}

Workerですが、Kotlin Coroutinesを使ったCoroutineWorkerが用意されていますので、Studyplus ではそれを利用しています。 CoroutineWorker の実行メソッドは suspend function のdoWork() です。

class MyEventWorker @AssistedInject constructor(
    @Assisted private val appContext: Context,
    @Assisted private val params: WorkerParameters
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        // TODO 1': ここでViewModelからのデータを扱いたい
    
        return runCatching { 
            // サーバのAPI呼び出しなど
        }.fold(
            // TODO 2': ここからViewModelへ結果を返したい
            onSuccess = { Result.success() },
            onFailure = { Result.failure() }
        )
    }
}

やり方

公式ドキュメントに倣います。

developer.android.com

Workerとのデータのやり取りにはandroidx.work.Dataクラスを用います。 このクラスはデータをMapで保持しています。

  • ViewModel -> Worker

    • WorkRequest のBuilderに用意されている setInputData(@NonNull Data inputData) で渡します
    • WorkerのinputDataから取得します
  • Worker -> ViewModel

    • Resultを返す際にDataを渡します
    • getWorkInfoByIdLiveData(request.id) メソッドを用いてWorkInfoクラスのLiveDataを取得します
const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

fun request(data: String) {
    // TODO 1: ここでWorkerにデータを渡したい -> setInputData(Data)
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    // TODO 2: ここでWorkerの実行結果を処理したい -> getWorkInfoByIdLiveData(request.id)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // 処理が終わった時に処理する場合はisFinished
        if (info.state.isFinished) {
            // info.outputDataで Data を受け取れる
            workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
        }
    }
}
override suspend fun doWork(): Result {
    // TODO 1': ここでViewModelからのデータを扱いたい -> inputData.get~~
    val requestData = inputData.getString(REQUEST_DATA_MAP_KEY)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        // TODO 2': ここからViewModelへ結果を返したい -> Result.~~(data)
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

これでデータの受け渡しを行えます。

さらにやりたいこと

Data でプリミティブな型以外を受け渡しする

Data クラスにはBuilder処理をラップした拡張関数である workDataOf が用意されています。

androidx.work.Data.kt から抜粋

/**
 * Converts a list of pairs to a [Data] object.
 *
 * If multiple pairs have the same key, the resulting map will contain the value
 * from the last of those pairs.
 *
 * Entries of the map are iterated in the order they were specified.
 */
inline fun workDataOf(vararg pairs: Pair<String, Any?>): Data {
    val dataBuilder = Data.Builder()
    for (pair in pairs) {
        dataBuilder.put(pair.first, pair.second)
    }
    return dataBuilder.build()
}

このパラメータを見る限り、 Pair<String, Any?> で一見なんでも入れられるように見えます。 なので私は最初、通常のデータクラスを入れるコードを書いたのですが実行したところクラッシュしました。 関数内で使われているput()メソッドについて、Data.Builderクラス本体の実装を追ってみます。

androidx.work.Data.java から抜粋

/**
 * Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer,
 * Long, Float, Double, String, and array versions of each of those types.
 * Invalid types throw an {@link IllegalArgumentException}.
 *
 * @param key A {@link String} key to add
 * @param value A nullable {@link Object} value to add of the valid types
 * @return The {@link Builder}
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public @NonNull Builder put(@NonNull String key, @Nullable Object value) {
    if (value == null) {
        mValues.put(key, null);
    } else {
        Class<?> valueType = value.getClass();
        if (valueType == Boolean.class
                || valueType == Byte.class
                || valueType == Integer.class
                || valueType == Long.class
                || valueType == Float.class
                || valueType == Double.class
                || valueType == String.class
                || valueType == Boolean[].class
                || valueType == Byte[].class
                || valueType == Integer[].class
                || valueType == Long[].class
                || valueType == Float[].class
                || valueType == Double[].class
                || valueType == String[].class) {
            mValues.put(key, value);
        } else if (valueType == boolean[].class) {
            mValues.put(key, convertPrimitiveBooleanArray((boolean[]) value));
        } else if (valueType == byte[].class) {
            mValues.put(key, convertPrimitiveByteArray((byte[]) value));
        } else if (valueType == int[].class) {
            mValues.put(key, convertPrimitiveIntArray((int[]) value));
        } else if (valueType == long[].class) {
            mValues.put(key, convertPrimitiveLongArray((long[]) value));
        } else if (valueType == float[].class) {
            mValues.put(key, convertPrimitiveFloatArray((float[]) value));
        } else if (valueType == double[].class) {
            mValues.put(key, convertPrimitiveDoubleArray((double[]) value));
        } else {
            throw new IllegalArgumentException(
                    String.format("Key %s has invalid type %s", key, valueType));
        }
    }
    return this;
}

Puts an input key-value pair into the Builder. Valid types are: Boolean, Integer, Long, Float, Double, String, and array versions of each of those types. Invalid types throw an {@link IllegalArgumentException}.

Boolean, Integer, Long, Float, Double, String 及びそれらのArrayのみ受け付けていることがわかります。 ドキュメントを参照すると、こちらにも明記されていますね。

ですので、プリミティブ型以外のデータクラスなどを受け渡したい場合は、JSON文字列にしてStringで受け渡しを行ないました。 SerializablePercelable も受け付けていないのは少々意外でしたが、今後追加されたら便利そうですね。

エラーハンドリングと進捗管理をする

Workerの処理が終わった時に処理を行いたいのであれば、doWork()の返り値を受け取り、isFinished()で確認して終了時のみ処理すれば十分です。

ですが、例えばエラー時のみの処理を実装したい、処理の進捗を取得したいなどの場合もあるかと思います。 その際には直接 workInfo.state で分岐してやりましょう。

進捗処理については 2.3.0-alpha1 から機能が追加されています。

developer.android.com

doWork()内でWorkerのsetProgressAsync(Data)を呼ぶことで、任意の箇所からDataを渡すことができます。 このDataWorkInfo.State.RUNNING ステータスとともに流れてきますので、その分岐の中で受け取ります。

const val REQUEST_DATA_MAP_KEY = "request_data_map_key"
const val RESULT_DATA_MAP_KEY = "result_data_map_key"
const val PROGRESS_DATA_MAP_KEY = "progress_data_map_key"

// 実行結果を受け取るLiveData
val workResultLiveData = MediatorLiveData<String>()

// 進捗状態を受け取るLiveData
val workProgressLiveData = MediatorLiveData<Int>()

fun request(data: String) {
    // Data作成
    val requestData = workDataOf(
        REQUEST_DATA_MAP_KEY to data
    )
    
    val request: OneTimeWorkRequest = OneTimeWorkRequestBuilder<MyEventWorker>()
        .setInputData(requestData) // Dataを添付
        .build()
    workManager.enqueue(request)
    workInfoLiveData.addSource(workManager.getWorkInfoByIdLiveData(request.id)) { info ->
        // stateで分岐
        when (info.state) {
            WorkInfo.State.RUNNING -> {
                // 進捗処理、進捗はここで受け取る。info.outputDataではなくinfo.progress
                workProgressLiveData.value = info.progress.getInt(PROGRESS_DATA_MAP_KEY)
            },
            WorkInfo.State.SUCCEEDED -> { 
                // 成功時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.FAILED -> { 
                // エラー時時処理(isFinishedに含まれる)
                workResultLiveData.value = info.outputData.getString(RESULT_DATA_MAP_KEY)
            },
            WorkInfo.State.CANCELLED -> { 
                // 処理のキャンセル時処理(isFinishedに含まれる)
                workResultLiveData.value = "CANCELLED"
            },
            else -> {
                // ここでは説明しませんが、他にENQUEUEDとBLOCKEDがあります
            }
        }
    }
}
override suspend fun doWork(): Result {
    val requestData = inputData.keyValueMap[REQUEST_DATA_MAP_KEY] as? String

    val progressDataStart = workDataOf(
        PROGRESS_DATA_MAP_KEY to 0
    )
    setProgressAsync(progressDataStart)

    // ~~ 何かしら時間のかかる処理など
    // 50%
    val progressDataHalf = workDataOf(
        PROGRESS_DATA_MAP_KEY to 50
    )
    setProgressAsync(progressDataHalf)

    // ~~
    
    val progressDataEnd = workDataOf(
        PROGRESS_DATA_MAP_KEY to 100
    )
    setProgressAsync(progressDataEnd)

    return runCatching { 
        // サーバのAPI呼び出しなど
    }.fold(
        onSuccess = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "success"
            )
            Result.success(resultData)
        },
        onFailure = {
            // Data作成
            val resultData = workDataOf(
                RESULT_DATA_MAP_KEY to "failure"
            )
            Result.failure(resultData)
        }
    )
}

最後に

今回は、WorkManager を使った非同期処理とのデータのやり取りについてお話しいたしました。

WorkManager は主にバックグラウンドの処理に使うものですが、結果をViewに反映したいケースなどもカバーされておりとても便利に使えるものだと思います。 今後もリリース動向にも注目していきたいですね。

JetpackのNavigationで開始画面を変更する方法

こんにちは、モバイルクライアントグループの隅山です。 去年からNavigationを導入してきましたが、導入する際に画面遷移周りで課題があったのでその課題について紹介します。

画面遷移の課題

スタディプラスのAndroidアプリはマルチモジュール構成となっているため、Navigationは機能モジュールごとに導入する方針としています。 Navigationを導入する上で、遷移元によって異なる画面を表示する機能の場合、どう実装するのかが課題として浮上しました。

一例としてユーザーが交流するコミュニティ機能をみてみます。この機能に遷移してくるパターンが以下の4つあり、それぞれコミュニティの最初の表示画面が異なります。

  • トップ画面→コミュニティ検索画面
  • ユーザ情報画面→コミュニティ一覧画面
  • 通知画面→コミュニティ詳細画面
  • 通知画面→コミュニティトピック詳細画面

それぞれ開始画面が異なる場合にNavigationでどう実装するか3つの方法を紹介します。

解決策1:StartDestinationを用いる

まず、一つ目にStartDestinationを用いる場合です。

StartDestinationとはNavGraphクラスの必須パラメータの一つです。このプロパティは開始画面を示しています。 NavGraphにsetStartDestination(@IdRes startDestId: Int)で開始画面を変更可能となっているため、下記のように変更することができます。

val navController = findNavController(R.id.nav_host_fragment)
val navGraph = navController.navInflater.inflate(R.navigation.community_nav_graph)

navController.graph = navGraph.apply {
    // StartDestinationで開始画面指定
    startDestination = R.id.communitySearchResultFragment
}

StartDestinationを用いる場合のメリットは、コード上で簡単に設定でき、nav_graph.xmlを変更する必要がない点です。 このため、設計にかかわらずすぐに導入したい場合に有効です。

使い所 メリット デメリット
開始画面が多い場合 nav_graph.xmlを変更する必要がない 特になし

解決策2:GlobalActionを用いる

続いて、二つ目はGlobalActionを用いる場合です。

GlobalActionとは同一NavGraph内であればどこからでも利用できる遷移のことです。 GlobalActionで最初に表示したい画面へ遷移することによって開始画面を変更することが可能です。詳しいコードは以下のようになります。

<!-- GlobalAction作成 -->
<action
    android:id="@+id/actionToSearchResult"
    app:destination="@id/communitySearchResultFragment" />
val navController = findNavController(R.id.nav_host_fragment)

// GlobalActionで開始画面に遷移
navController.navigate(
    ActionOnlyNavDirections(R.id.actionToSearchResult)
)

GlobalActionは汎用的な画面へ遷移する場合に効果的な機能です。そのため、GlobalActionを用いる方法はそのような画面が開始画面の場合に有効です。 ただ、GlobalActionを多用しすぎると、GUI上で遷移関係が追いにくくなってしまうため注意が必要です。

使い所 メリット デメリット
汎用的な画面への遷移の場合 遷移が複雑でも遷移図がシンプル GUI上で遷移関係が追いにくい

GlobalActionを用いる場合の注意

※NavGraphのstartDestinationへ遷移した直後にGlobalActionで指定位置へ遷移しているため、UI的には開始画面が変更できているが、コード上は2回の遷移が起きています。 そのため、GlobalActionの遷移先から戻った場合、NavGraphのstartDestinationに戻ります。

解決策3:NavGraphの分割

最後に、NavGraphを分ける場合です。

NavGraphの分割では開始画面ごとにNavGraphを切り分けて、最初に表示したい画面のNavGraphをNavControllerに設定することで開始画面を変更できます。 分割したNavGraphはNestedGraphを用いることでNavGraphを跨いだ遷移が実現できます。

NestedGraphの説明は今回の内容と少し外れてしまうため、説明を割愛させていただきます。

<!-- community_nav_graph_1.xml -->
<fragment
    android:id="@+id/communitySearchFragment"
    android:name="xxx.xxx.CommunitySearchFragment">

    <!-- NestedGraphで遷移するアクション -->
    <action
        android:id="@+id/actionToSearchResult"
        app:destination="@+id/community_nav_graph_2" />

</fragment>

<include app:graph="@navigation/community_nav_graph_2" />
<!-- community_nav_graph_2.xml -->
<fragment
    android:id="@+id/communitySearchResultFragment"
    android:name="xxx.xxx.CommunitySearchResultFragment" />
val navController = findNavController(R.id.nav_host_fragment)

// 開始したい画面のNavGraphを設定
if (isStart1) {
    navController.setGraph(R.navigation.community_nav_graph_1)
} else {
    navController.setGraph(R.navigation.community_nav_graph_2)
}

NavGraphの分割のメリットは、NavGraphスコープのViewModelでデータ共有できることです。 デメリットはNavGraphが分割されるため、GUI上で機能全体の遷移が追いにくい点です。

使い所 メリット デメリット
NavGraphViewModelでデータ共有したい場合 データの受け渡しが不要 GUI上で機能全体の遷移が追いにくい

採用した解決策

- 開始地点を変更 適した設計
StartDestination 実装可能 どんな設計でも有効
GlobalAction 実装可能(BackStackに注意) 汎用的な画面から開始したい場合
NavGraphの分割 実装可能 ViewModelでデータ共有したい場合

スタディプラスのコミュニティ機能では、解決策3のNavGraphの分割を採用しました。

開始地点を変更するだけならどの解決策でも実装可能なのですが、NavGraphの分割のメリットであるNavGraphViewModelを利用するためです。 コミュニティ画面ではサーバからコミュニティデータ取得して各画面でそのデータを表示することが多く、できるだけデータ取得回数を減らすためにViewModelで共有しました。

今回3つの方法を紹介しましたが、どれを採用すべきかは設計次第であるためどれがいいとは一概に言えません。 現に、スタディプラスのAndroidアプリ内では機能ごとに採用している解決策が異なっています。 どの解決策にすべきかは設計と実際に実装してみてご検討ください。

終わりに

Navigationで画面遷移を導入する際の課題を紹介しましたが、上記の事例以外では画面遷移で詰まることなく、むしろ画面遷移がGUIで簡単に実装することができました。

これからアプリを作る方はもちろん、大規模なアプリの開発に携わっている方も機能ごとや部分部分に分けて導入可能なので、是非導入してみてください。

Nuke + UIImageViewでいい感じにURLを読み込ませたい!

お久しぶりです。 モバイルクライアントグループの若宮(id:D_R_100)です。

もともとはAndroidアプリ専任だったのですが、昨年11月ごろよりiOSアプリ開発にも参加するようになりました。 今回は、iOSアプリに参加して取り組んでいたNukeによる画像読み込み処理改善についてまとめてみます。

画像の読み込み事情

Studyplusのクライアントアプリは画像の表示箇所が非常に多いアプリです。 ユーザーアイコンや特集記事のアイキャッチ、連携しているアプリアイコンなど至る所に画像が存在しています。

これらの画像は、それぞれのタイミングでSteakと呼ばれる社内システムから取得しています。 サーバー側の実装については、過去のエントリをご参照ください。

tech.studyplus.co.jp

Studyplus iOSアプリの歴史は古いため、もともと画像の読み込み処理を自前で実装していました。 また、その名残で画面サイズに応じたリサイズ済みの画像をViewごとにリクエストしていました。

こういった事情の中、iOSアプリ開発に余剰な戦力として参加することになりました。 AndroidではGlideを利用していた経験などもあったため、"お試し"で入っていたNukeをアプリ内で全面採用できるよう対応を進めるタスクに取り組みました。

Nuke + スタディプラスiOSアプリ

NukeはiOSアプリ開発で非常に人気のあるライブラリです。

github.com

余談となりますが、今回Nukeについていろいろと調べている時に下記のようなOSSライブラリを比較するページを見つけました。 有名ライブラリの活発さを比較できるのは面白いですね。 下記はKingfisherとNukeの比較ですが、単にGithubのリポジトリを見比べても気付きにくいところが比較できるので、選択のしやすさが高いなと感じます。

ios.libhunt.com

開発方針

開発を行うにあたりiOSのライブラリや既存コード、Androidライブラリの知見などを組み合わせて下記の方針を立てました。

  1. デフォルト画像を用意する側が意識せずとも、メモリに優しい実装とする
  2. イニシャライザの引数で、Viewを構成する要素を与え余計な変更の余地を残さない
  3. JSONのパラメーターをパースして渡すことになるため、nil許容で期待する動作を実現する
  4. enumを利用し、Xcode上で簡単に呼び出しができるようにする

デフォルト画像については、iOSの UIImage のドキュメントを確認したところ named 引数をとるイニシャライザを利用することで自動的に管理されることがわかりました。 このため named 引数をとる UIImage を使いやすくすることで、メモリに優しい処理が自然と利用できるようにしました。

https://developer.apple.com/documentation/uikit/uiimage

Use the init(named:in:compatibleWith:) method (or the init(named:) method) to create an image from an image asset or image file located in your app’s main bundle (or some other known bundle). Because these methods cache the image data automatically, they are especially recommended for images that you use frequently.

それ以外の方針については、次の節からコードを参考に説明していきたいと思います。

Nukeを呼び出すexクラスの作成

スタディプラスiOSアプリでは、アプリ内で利用する拡張関数を作成する際、Extensionに名前空間を作成しています。 これは意図しない関数の上書きを防ぐことや、コードの可読性をあげることを目的にしています。

この手法は下記のような記事を参考に取り入れているものです。

techblog.zozo.com

qiita.com

この手法に則り、今回は ex.loadUrl と呼び出せるよう関数を追加しました。 合わせて、アプリ内で画像のリサイズモードをOSに(そこまで)依存せずに呼び出せるよう ProcessorsOption というenumを作成しています。

public enum ProcessorsOption {
    case resize
    case resizeRound(radius: CGFloat)
    case resizeCircle
}

public typealias AspectMode = ImageProcessor.Resize.ContentMode

public extension Extension where Base == UIImageView {
    
    func loadUrl(imageUrl: String?,
                 processorOption: ProcessorsOption = ProcessorsOption.resize,
                 aspectMode: AspectMode = .aspectFill,
                 crop: Bool = false,
                 defaultImage: UIImage? = nil,
                 contentMode: UIView.ContentMode? = nil) {
        loadUrl(imageUrl: imageUrl,
                processorOption: processorOption,
                aspectMode: aspectMode,
                crop: crop,
                placeHolder: defaultImage,
                failureImage: defaultImage,
                contentMode: contentMode)
    }
    
    func loadUrl(imageUrl: String?,
                 processorOption: ProcessorsOption = ProcessorsOption.resize,
                 aspectMode: AspectMode = .aspectFill,
                 crop: Bool = false,
                 placeHolder: UIImage? = nil,
                 failureImage: UIImage? = nil,
                 contentMode: UIView.ContentMode? = nil) {
        guard let url: String = imageUrl else {
            base.image = failureImage
            return
        }
        guard let loadUrl: URL = URL(string: url) else {
            base.image = failureImage
            return
        }
        
        let resizeProcessor = ImageProcessor.Resize(size: base.bounds.size, contentMode: aspectMode, crop: crop)
        let processors: [ImageProcessing]
        switch processorOption {
        case .resize:
            processors = [resizeProcessor]
        case .resizeRound(let radius):
            processors = [resizeProcessor, ImageProcessor.RoundedCorners(radius: radius)]
        case .resizeCircle:
            processors = [resizeProcessor, ImageProcessor.Circle()]
        }
        
        let request = ImageRequest(
            url: loadUrl,
            processors: processors
        )
        var contentModes: ImageLoadingOptions.ContentModes?
        if let mode = contentMode {
            contentModes = ImageLoadingOptions.ContentModes.init(success: mode, failure: mode, placeholder: mode)
        }
        let loadingOptions = ImageLoadingOptions(placeholder: placeHolder, failureImage: failureImage, contentModes: contentModes)
        
        Nuke.loadImage(with: request, options: loadingOptions, into: base)
    }
}

ロード中と失敗時の画像として同一の UIImage を用いるケースが多いため、画像指定の分岐を吸収する関数を用意しています。 また UIImageView の拡張にすることで、 UIImageView のサイズに応じたリサイズ処理を実施しています。 サーバー側で実装していた画像のリサイズ処理をアプリ側に移譲することで、アプリ側でディスクキャッシュを利用するなどの機能改善の余地を作ることができました。

URLを受け取るUIImageViewの作成

続いてアプリ内でよく使う切り抜き処理などを加えた、汎用的なImageViewを作成します。 DefaultIconName を用意すると、利用時にenumを選択することで named を引数としたUIImageが作成されるようになります。

enum DefaultIconName: String {
    case user = "default_icon_user"
    case university = "record_icon_university"
}

extension DefaultIconName {
    func createIcon() -> UIImage? {
        return UIImage(named: self.rawValue)
    }
}

final class UrlImageView: UIImageView {
    
    enum ShapeType {
        case square
        case round(radius: CGFloat)
        case circle
    }
    
    enum EdgeType {
        case none
        case edge
    }
    
    private var imageUrl: String?
    private var crop: Bool
    private var shapeType: ShapeType
    private var defaultImage: UIImage?
    private var edgeType: EdgeType
    private var aspectMode: AspectMode
    private var loadImageContentMode: ContentMode?

    convenience init(size: CGSize,
                     imageUrl: String? = nil,
                     crop: Bool = false,
                     shapeType: ShapeType = .square,
                     defaultIconName: DefaultIconName,
                     edgeType: EdgeType = .none,
                     aspectMode: AspectMode = .aspectFill,
                     loadImageContentMode: UIView.ContentMode? = nil) {
        self.init(size: size,
                  imageUrl: imageUrl,
                  crop: crop,
                  shapeType: shapeType,
                  defaultImage: defaultIconName.createIcon(),
                  edgeType: edgeType,
                  aspectMode: aspectMode,
                  loadImageContentMode: loadImageContentMode)
    }
    

    init(size: CGSize,
         imageUrl: String? = nil,
         crop: Bool = false,
         shapeType: ShapeType = .square,
         defaultImage: UIImage? = nil,
         edgeType: EdgeType = .none,
         aspectMode: AspectMode = .aspectFill,
         loadImageContentMode: UIView.ContentMode? = nil) {
        self.imageUrl = imageUrl
        self.crop = crop
        self.shapeType = shapeType
        self.defaultImage = defaultImage
        self.edgeType = edgeType
        self.aspectMode = aspectMode
        self.loadImageContentMode = loadImageContentMode
        
        super.init(frame: CGRect(origin: .zero, size: size))
        
        self.image = defaultImage
        
        contentMode = .scaleAspectFit
        clipsToBounds = true
        isUserInteractionEnabled = true
        
        drawImage()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    /// Reset image view for [UITableViewCell.prepareForReuse](https://developer.apple.com/documentation/uikit/uitableviewcell/1623223-prepareforreuse)
    func reset() {
        image = defaultImage
        imageUrl = nil
    }
    
    /// Set the image URL and load the image from the network
    ///
    /// - Parameters:
    ///   - imageUrl: a url for loading an image
    func setImageUrl(imageUrl: String?) {
        self.imageUrl = imageUrl
        drawImage()
    }
    
    private func drawImage() {
        switch shapeType {
        case .square:
            switch edgeType {
            case .none:
                drawSquare()
            case .edge:
                drawEdgedSquare()
            }
        case .round(let radius):
            switch edgeType {
            case .none:
                drawRound(radius: radius)
            case .edge:
                drawEdgedRound(radius: radius)
            }
        case .circle:
            switch edgeType {
            case .none:
                drawCircle()
            case .edge:
                drawEdgeCircle()
            }
        }
    }
    
    private func drawSquare() {
        loadImage()
    }
    
    private func drawEdgedSquare() {
        drawEdge()
        loadImage()
    }
    
    private func drawRound(radius: CGFloat) {
        self.layer.cornerRadius = radius
        
        loadImage()
    }
    
    private func drawEdgedRound(radius: CGFloat) {
        self.layer.cornerRadius = radius
        
        drawEdge()
        loadImage()
    }
    
    private func drawCircle() {
        layer.cornerRadius = width * 0.5
        
        loadImage()
    }
    
    private func drawEdgeCircle() {
        layer.cornerRadius = width * 0.5
        
        drawEdge()
        loadImage()
    }
    
    private func loadImage() {
        (self as UIImageView).ex.loadUrl(imageUrl: imageUrl, processorOption: .resize, aspectMode: aspectMode, crop: crop, defaultImage: image, contentMode: loadImageContentMode)
    }
    
    private func drawEdge() {
        layer.borderColor = UIColor.defaultTint().cgColor
        layer.borderWidth = 0.5
    }
}

デフォルト画像の角丸や円形の切り抜きに対応するため UrlImageView の角に処理を加えています。 こういったImageViewの操作がiOSは対応しやすいので、大変感動しました。

コード追加による効果

UrlImageView を導入したことで、旧来の画像読み込み処理を素早く置き換えることができました。 SwiftのコードをObjective-Cから呼び出すことで、手を出しづらかった歴史的なコードの改修ができるようになったことは、個人的なモチベーションの向上にもつながっています。

その他には、一部の画像において旧来の処理よりも解像度がよくなったように見える箇所が存在しています。 旧来のコードのリファクタリングは少々困難な状態にあったため、良い形でリプレースすることができました。

終わりに

今回が初めて業務でのiOS開発でした。 SwiftやXcodeをはじめ戸惑うことは多かったのですが、Appleのドキュメントが非常に充実しており大変開発に取り組みやすかったです。

今後もアレコレと対応を進め、iOSアプリの体験を向上できればと考えています!

Firebase App DistributionへFabric Betaから社内向けアプリ配信を移行しました

こんにちは、新生モバイルクライアントグループの若宮(id:D_R_1009)です。 今回は社内向けのテストアプリ配信の更新について書きたいと思います。

移行の経緯

スタディプラスAndroidチームでは歴史的な経緯により、Fabric Betaを長らく利用していました。 しかしながら、2020年3月をもってFabric Betaがクローズすることになったため別のサービスへ移行する必要が生じました。

get.fabric.io

いくつかのサービスを検討し、結局Firebase App Distributionを利用することとしました。 いつの間にかベータ版になっていたのが決め手です。

firebase.google.com

試行錯誤したところ、今までと変わらない(一部より利便性を増して)環境構築をできたので共有したいと思います。 「もっといい方法があるよ!」などあれば、ブックマークにコメントを付けて頂けると嬉しいです!

テストアプリ配信環境

テストアプリを配信するにあたり、次の2つの方法を用意しています

  1. CI上で次期リリース用PR作成時に検証環境( Stage )/開発環境( Debug )向きのapkを作成し配信する
  2. ローカル環境(開発者のマシン)から検証環境( Stage )/開発環境( Debug )向きのapkを作成し配信する

1つ目はよくあるCD環境ではないかなと思います。 リリース直前に一通りアプリを触ってテストするため、リリース用PRをベースにビルドを走らせています。 また、サーバーチームのエンジニアが実際にアプリからサーバーAPIを叩く処理を触ることがあるため、社内向けにストアから配信したコードとほぼ同じものを配信する目的もあります。

2つ目のローカルマシンによる実行を用意しているのは、開発者が少なく機能ごとの開発が多いため、検証環境向きのデイリービルドを行っていないためです。 動いているプロジェクトの状況に応じて、またサーバーチームやデザイナーの求めに応じてビルドを走らせてるため、ローカル環境からアップロードできる仕組みを用意しています。

今回Firebase App Distributionを利用するにあたり、このCIとローカルの2つの環境でビルドを走らせるのが少々難しくなっていました。

fastlaneによるlaneの用意

CIとローカル環境用のlaneを用意しておきます。 大きな違いとしてはCI用のlaneでは firebase cli tokenfirebase cli path を設定しておきますが、ローカル環境用では用意していません。

default_platform(:android)

  desc "Debug build and upload Firebase Distribution(for CI)"
  lane :build_and_deploy_debug do
    gradle(
      task: "assemble",
      build_type: "Debug"
    )
    firebase_app_distribution(
      app: ENV["APP_ID_DEBUG"],
      testers_file: "./tester/testers.txt",
      groups_file: "./tester/groups.txt",
      release_notes_file: "./app/BetaDistributionReleaseNotes.txt",
      firebase_cli_token: ENV["FIREBASE_CLI_TOKEN"],
      firebase_cli_path: "./node_modules/.bin/firebase"
    )
  end

  desc "Debug build and upload Firebase Distribution(for local machine)"
  lane :build_and_deploy_debug_local do
    gradle(task: 'clean')
    gradle(
      task: "assemble",
      build_type: "Debug"
    )
    firebase_app_distribution(
      app: ENV["APP_ID_DEBUG"],
      testers_file: "./tester/testers.txt",
      groups_file: "./tester/groups.txt",
      release_notes_file: "./app/BetaDistributionReleaseNotes.txt"
    )
  end
end

CI用の FIREBASE_CLI_TOKEN は公式ドキュメントの通り firebase login:ci を利用して取得しています。

Firebase CLI reference  |  Firebase

Firebase App Distributionの利用はいくつか選択肢がありますが、個人的には fastlane の利用が一番やりやすいのではないかと思います。

firebase.google.com

CircleCIではAndroid用のDocker ImageにRubyの環境が用意されているため、すぐに利用することができます。 もしも fastlane の導入が気になるようでしたら、下記記事を是非ご覧ください。

tech.studyplus.co.jp

CI(CircleCI)の設定

CircleCIではグローバルな環境にFirebase CLIをインストールできないため、作業ディレクトリにnpmを利用してFirebase CLIを用意します。 標準のAndroid用Docker Imageではnodeが用意されていないため -node 付きのnodeが利用できるImageを利用するのが良いと思われます。

version: 2.1
executors:
  android_defaults:
    docker:
      - image: circleci/android:api-29-node

commands:
  setup_bundle:
    steps:
      - restore_cache:
          keys:
            - v1-bundle-cache-{{ checksum "Gemfile.lock" }}
            - v1-bundle-cache-
      - run:
          name: Set path and clean option
          command: |
            bundle config set path './vendor/bundle'
            bundle config set clean 'true'
      - run:
          name: bundle install
          command: bundle check || bundle install --jobs=4 --retry=3
      - save_cache:
          paths:
            - ./vendor/bundle
          key: v1-bundle-cache-{{ checksum "Gemfile.lock" }}

  setup_node:
    steps:
      - restore_cache:
          keys:
            - v1-npm-cache-{{ checksum "package-lock.json" }}
            - v1-npm-cache-
      - run:
          name: npm install
          command: npm install
      - save_cache:
          paths:
            - ./node_modules
          key: v1-npm-cache-{{ checksum "package-lock.json" }}

  setup_android:
    steps:
      - restore_cache:
          keys:
            - v1-gradle-cache-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
            - v1-gradle-cache-
      - run:
          name: Download Dependencies
          command: ./gradlew androidDependencies
      - save_cache:
          paths:
            - /home/circleci/.gradle
          key: v1-gradle-cache-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
  run_build_debug:
    steps:
      - run:
          name: Create new debug build(.apk) and upload Firebase Distribution
          command: bundle exec fastlane android build_and_deploy_debug

jobs:
  build_debug:
    executor:
      name: android_defaults
    steps:
      - checkout
      - setup_bundle
      - setup_node
      - setup_android
      - run_build_debug

workflows:
  deploy:
    jobs:
      - build_debug:
          filters:
            branches:
              only:
                - /^release.*/

Firebase App Distributionへapkを送信する処理がfastlaneに閉じているため、CircleCIではキャッシュの管理程度のステップになります。 なお実運用しているステップでは、途中でリリースノートにブランチ名やコミットログの書き込みなどを行っています。

開発者マシン(shell script)の設定

Mac OSとLinux向けに、Firebase CLIがインストールコマンドを叩いている環境に応じて環境構築をしてくれるスクリプトを公開しています。

Firebase CLI reference  |  Firebase

スタディプラスのモバイルクライアントチームは全員がMacBookProを利用しているため、こちらのスクリプトを採用しました。 このことにより firebase login コマンドの対話的な処理で firebase cli token に該当するリフレッシュトークンを取得することができるようになります。

#!/bin/zsh
echo "Build debug apk, and upload Firebase Distribution"

echo "Login firebase"

curl -sL https://firebase.tools | bash
firebase login

echo "Update local module"

# bundle
bundle config set path './vendor/bundle'
bundle config set clean 'true'
bundle install

echo "Start build"

# Build and upload
bundle exec fastlane android build_and_deploy_debug_local

echo "Done!"

事前にRubyやrbenvによる環境構築が必要となりますが(Bundlerのバージョンアップのため)、一度設定するだけだったので共有コストもほとんどかかりませんでした。 実行時間も短い(6分程度)のため、気軽に実行できる環境が維持されています。

終わりに

今回はFabric BetaからFirebase App Distributionへの移行事例を紹介しました。 実作業時間としては3〜4時間程度だっため、作業負荷もそこまで高くなく、改めてFirebaseの使いやすさを確認することとなりました。

現状ではアプリサイズが原因なのか、Debugのビルド時間にばらつきがあるため、引き続きCI設定を改善していきたいと思います!

開発プロセスを振り返ってみた話

こんにちは、サーバーサイドチームの山下です。 今回は昨年末にチームで実施した開発プロセスの振り返り会についてお話します。

背景

これまでの開発

私が所属しているサーバーサイドチームは、学習記録システムや特集記事配信システムなど、複数のシステムを扱っています。新規機能開発や改修があるとプロジェクトが発足し、各メンバーがアサインされ他チームと連携しながら開発を進める形式がメインです。

そのため、私が入社した時のチームは各々作業を進める雰囲気が強く、どうしても特定のシステムや機能開発が属人化してしまったり、他のメンバーが困っていることに気付けないということがありました。

そんな中、CSM(認定スクラムマスター)の資格を取得したエンジニアが中心となり2019年の夏頃からスクラムでの開発にチャレンジしていました。

なぜ振り返りをしたのか

2019年末のタイミングでこれまでスクラムイベントなど主導してくれていたスクラムマスターがチームを離れることになり、またその他のメンバーも数名入れ替ることになりました。

もともとスクラム自体も「試しにやってみよう!」という形で始まったので、やってみてどうだったか・今後どうしていくかをチームとして認識を合わせる良い機会だと思い、振り返りの場を設けることにしました。

準備

せっかくやるなら今後の開発に活かせるよう、有意義な会にしたく以下の準備や環境作りをしました。

  1. 事前にアジェンダ・タイムボックスを決めシミュレーションしておく
  2. 振り返り中はPCを開かせないようにする

1については当たり前のことかもしれませんが、どうすれば時間内で目的を達成できるか事前にスクラムマスターと一緒に考え、振り返り方法やタイムボックスを決めました。 普段のレトロスペクティブでやっていたKPTではおそらく時間オーバーしたり、まとまらなかったりしていたと思うので、シミュレーションはやっておいて正解でした。

また、2についてはPCを開くとどうしてもslackや他のタスクが気になってしまうので、テーブルは除けて椅子とホワイトボードのみの場にしました。これも議論に集中できて良かったと思います。

振り返りの流れ

全員でまとまった時間をとるのが難しかったため、二部制にしました。

  • [第一部] スクラムやってみてどうだった会
  • [第二部] 今後どうしましょうか会

[第一部] スクラムやってみてどうだった会

この会の目的は「今後の開発プロセスをどうするか決めるために、これまでのプロセスを振り返る」としました。 スクラムイベント毎にGood(良かったこと・続けたいこと)とMotto(改善したいこと・不満)を付箋に書き、各々その理由を共有していきました。 f:id:syamashi:20200109113438j:plain

[第二部] 今後どうしましょうか会

第二部では、第一部の内容を踏まえ今後のプロセスをどうしていくか話し合います。 まずは第一部でGoodになっていることは今後も続けていくことをチームとして合意し、Mottoになっていることをどう改善していくかを決めました。 全てのMottoについて検討すると時間がかかりすぎるのでドット投票で絞って改善案をだしていきました。 以下のような流れです。

  1. Good/Mottoを思い出す
  2. ドット投票で解決したいMottoを決める
  3. 解決案を出す
  4. 解決案の中から試して見るものを決める

f:id:syamashi:20200109113705j:plain

やってみての感想

通常のレトロスペクティブではどうしても普段の作業についての振り返りがメインとなり、スクラムイベント自体を振り返る機会がなかなかありませんでした。

各メンバーが普段の開発プロセスに対してどんな期待や不満があるのか、どうしたら改善できるかを話し合えたのはとても良かったと思います。 また、事前にシミュレーションなどしていたため当日はスムーズに進めることができ、改めて準備や場作りは大切だと感じました。

まとめ

今回は私達のチームで行った開発プロセスの振り返りについてお話しました。

皆さんのチームでもメンバーの入れ替わりなどチーム状況が変わったり、ちょっとマンネリ化してきたなと感じたら、普段の作業プロセス自体を振り返ってみるのものよいかもしれません。

WorkManager + Dagger2によるバックグラウンド処理

こんにちは、Androidチームの若宮(id:D_R_1009)です。 昨年末にAndroidチームが導入した、WorkManagerをDagger2と組み合わせる方法を紹介します。

WorkManagerとは

developer.android.com

WorkManagerは、確実に実行したい非同期処理に対して利用するAndroid Architecture Componentsとなります。 概要についてはDroidKaigi 2019の「実践 WorkManager」をご一読ください。

speakerdeck.com

また、CodeLabも用意されています。

codelabs.developers.google.com

WorkManager + Dagger2(DI)

StudyplusのAndroidアプリではDagger2を活用しているため、WorkManagerとDagger2を組み合わせる必要があります。 検索してみると、次の記事で組み合わせ方が紹介されていました。

proandroiddev.com

もともとAssistedInjectを利用していたこともあり、おおよそこの方法で導入することはできそうでした。 ただ、できれはWorkManagerも provide メソッドによる管理を行いたくなります。

と言うことで、対応していきます。

provide 対応

WorkManagerはインスタンス化する処理が(特に指定しなければ)デフォルトのものが利用されるため、この処理を切り替える必要があります。 対応したのがWorkManager 2.1のため、ドキュメントに従って AndroidManifest.xml に記述を加えます。

developer.android.com

この対応により、 WorkManager.getInscance(context) する前に Configuration.Provider をセットすることができます。

@Provides
@Singleton
fun provideWorkManager(
    context: Context,
    factory: WorkManagerFactory
): WorkManager {
    WorkManager.initialize(context, Configuration.Builder().setWorkerFactory(factory).build())

    return WorkManager.getInstance(context)
}

あとは記事の通り、諸々のモジュールをセットすれば完了です。

@Module(includes = [PresenterModule::class, WorkerBindingModule::class])
object WorkManagerModule {

    @Provides
    @Singleton
    fun provideWorkManager(
        context: Context,
        factory: WorkManagerFactory
    ): WorkManager
}

@Module(includes = [AssistedInject_PresenterModule::class])
@AssistedModule
internal interface PresenterModule

@MapKey
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class WorkerKey(val value: KClass<out ListenableWorker>)

@Module
interface WorkerBindingModule {
    @Binds
    @IntoMap
    @WorkerKey(HogeWorker::class)
    fun bindHogeWorker(factory: HogeWorker.Factory): ChildWorkerFactory
}

利用状況、所感

StudyplusのAndroidアプリでは、WorkManagerをRepository層とViewModel層の間にある概念として利用しています。 このため、DIによりViewModel層からWorkManagerを呼び出し、WorkManagerのWokerにRepositoryをInjectして非同期処理を実行する構成となります。

アプリケーションの各種ライフサイクルに影響を受けずに非同期処理を実行することができ、開発のしやすさが高まったと感じています。 アーキテクチャとしてMVVMを採用しているチームにおいては、有用な選択肢となるのではないでしょうか。

終わりに

簡単ではありますがWorkManagerとDagger2を組み合わせる方法の紹介と、その効果をまとめてみました。 WorkManagerはKotlin Coroutinesを利用することができるため、Repositoryの各メソッドをsuspend関数にしておくだけで、簡単に呼び出すことができます。

2020年も引き続き、よろしくお願いいたします。