Studyplus Engineering Blog

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

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に反映したいケースなどもカバーされておりとても便利に使えるものだと思います。 今後もリリース動向にも注目していきたいですね。