はじめに
こんにちは!
andfactory App Div所属Androidエンジニアの久住です!
12月も中盤にさしかかってきて、忘年会やら年末調整やらで何かと忙しい時期ですね。
突然ですが、皆さんはAndroidの非同期通信ってどのように実装してますか?
ドメイン駆動設計やレイヤードアーキテクチャなど、大枠の考え方はすぐに決められても、「レイヤー間の値の受け渡しはどうするか」とか「取得した値をどのようにしてViewに反映するか」など、細部については考えることも多くてなかなか決めづらいですよね!
というわけで!
僕の記事では、Google I/O 2019の公式アプリのコードから、Androidにおけるモダンな非同期通信の実装方法を見ていきたいと思います!
これでプロジェクト立ち上げ時にも、ズバっと非同期通信の実装方針を決められちゃう(かも)!
プロジェクト構成
非同期通信に関係のあるディレクトリだけ切り抜いてみます。
iosched
├ mobile → Presentation層(View/ViewModel)
├ model → 各種モデル
└ shared
├ data → Repository層/Data Source
└ domain → Service層
気になったポイント
プロジェクト構成を見ていて特に気になったのは、shared
に含まれているクラスについてです。
ここには各種UseCaseと、それらが依存しているRepositoryが格納されています。
僕が前にアサインされていたプロジェクトでは、Service層(UseCase)とRepository層は別のモジュールに格納されていました。
Repositoryではデータの取得を、Serviceではビジネスロジックを実装するのが一般的ですので、わかれているのが直感的なように思います。
自分なりにこの二つがわかれている理由を考えてみた結果、**『閉鎖性共通の原則』**に則った結果なのかな、という結論に至りました。
【閉鎖性共通の原則】
同じ理由、同じタイミングで変更されるクラスをコンポーネントにまとめること。変更の理由やタイミングが異なるクラスは、別のコンポーネントに分けること。
※参考 : Clean Architectre
View周りを修正する場合はmobile
モジュールにまとめられたActivity/Fragment/ViewModelを、データ取得処理を修正する場合はshared
モジュールにまとめられたUseCase/Repositoryを触るのが、アーキテクチャ的には綺麗という判断だったのかもしれませんね。
依存関係
実際には、Daggerのコンストラクタインジェクションを使っています。
こんな感じ。
class AgendaViewModel @Inject constructor(
loadAgendaUseCase: LoadAgendaUseCase,
getTimeZoneUseCase: GetTimeZoneUseCase
) : ViewModel() {
// いろんな処理
}
気になったポイント
UseCase→Repository
間やRepository→DataSource
間も、同様にコンストラクタインジェクションを使っています。
今後は「Koinを使うか、Daggerを使うか」みたいな差分はあっても、コンストラクタでインジェクトしてレイヤー間の繋がりを作っていく、という構成は共通認識になっていくかもしれないですね!
各レイヤーの作り
ここからは、ViewModel
UseCase
Repository
がどのようにして依存するコンポーネントからデータをもらっているかをみていきます。
コードは特にシンプルな作りになっていたAgenda取得のものを参考にします。
Repository
Repositoryの実装はこんな感じ。
AgendaRepository
class DefaultAgendaRepository(private val appConfigDataSource: AppConfigDataSource)
: AgendaRepository {
private val blocks by lazy {
generateBlocks(appConfigDataSource)
}
/**
* Generates a list of [Block]s. Default values of each [Block] is supplied from the
* default values stored as shared/src/main/res/xml/remote_config_defaults.xml.
* Add a corresponding entry in RemoteConfig is any [Block]s need to be overridden.
*/
private fun generateBlocks(dataSource: AppConfigDataSource): List<Block> {
return listOf(
Block(
title = "Badge pick-up",
type = "badge",
color = 0xffe6e6e6.toInt(),
startTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BADGE_PICK_UP_DAY0_START_TIME).value),
endTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BADGE_PICK_UP_DAY0_END_TIME).value)
),
Block(
title = "Badge pick-up",
type = "badge",
color = 0xffe6e6e6.toInt(),
startTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BADGE_PICK_UP_DAY1_START_TIME).value),
endTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BADGE_PICK_UP_DAY1_END_TIME).value)
),
Block(
title = "Breakfast",
type = "meal",
color = 0xfffad2ce.toInt(),
startTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BREAKFAST_DAY1_START_TIME).value),
endTime = ZonedDateTime.parse(
dataSource.getStringLiveData(BREAKFAST_DAY1_END_TIME).value)
)
// このあと同じようなBlockオブジェクトが何個か続く。
)
}
override fun getAgenda(): List<Block> = blocks
/**
* Returns a list of [Block]s as [LiveData].
* This is needed because each start/end time could be overridden from RemoteConfig.
* When the time is updated from RemoteConfig, the List needs to be observable otherwise
* the value change in RemoteConfig isn't effective unless restarting the app.
*/
override fun getObservableAgenda(): LiveData<List<Block>> {
val result: MutableLiveData<List<Block>> = MutableLiveData()
result.postValue(getAgenda())
appConfigDataSource.syncStringsAsync(object : StringsChangedCallback {
override fun onChanged(changedKeys: List<String>) {
if (!changedKeys.isEmpty()) {
result.postValue(generateBlocks(appConfigDataSource))
}
}
})
return result
}
}
気になったポイント
Repositoryで一番気になったのは、syncStringAsync()
の部分ですね。
Listを監視しておいて、変更が入ったタイミング更新がかかるようにしているっぽいですね。
ぶっちゃけここまだ何やってるかきちんと理解できていないので、またしっかり見る時間を取ろうと思ってます。
UseCase
UseCaseのコードはこんな感じ。
LoadAgendaUseCase
open class LoadAgendaUseCase @Inject constructor(
private val repository: AgendaRepository
) : MediatorUseCase<Unit, List<Block>>() {
override fun execute(parameters: Unit) {
try {
val observableAgenda = repository.getObservableAgenda()
result.removeSource(observableAgenda)
result.addSource(observableAgenda) {
result.postValue(Result.Success(it))
}
} catch (e: Exception) {
result.postValue(Result.Error(e))
}
}
}
気になったポイント
ここで気になったことは2つあります。
1つ目がエラーハンドリングの方法について。
データ取得時にExceptionが発生した場合は、Result.Errorのオブジェクトを作ってpostValue()
しているようです。
ViewModel側ではas?
を使ってキャストすることで、Errorだった場合にnullに変換しています。
(it as? Result.Success)?.data ?: emptyList()
めちゃくちゃスマートですね。
2つ目は、MediatorUseCaseを継承しているところです。
MediatorUseCaseは下記のようにシンプルな実装になっていて、Repositoryからデータの取得が完了した時に、UseCaseでaddSource()
・postValue()
を実行してあげることで、ViewModelに値を渡してあげています。
MediatorUseCase
abstract class MediatorUseCase<in P, R> {
protected val result = MediatorLiveData<Result<R>>()
// Make this as open so that mock instances can mock this method
open fun observe(): MediatorLiveData<Result<R>> {
return result
}
abstract fun execute(parameters: P)
}
図にするとこんな感じ。
RxやCoroutineを使わずに、LiveDataを使って値の受け渡しをしているところが特徴的ですね。
関数の返り値の型を指定しなくて良くなるので、疎結合化が進んでとっても良い感じです!
ViewModel
ViewModelのコードはこんな感じ。
こいつが持っているLiveDataをfragment_agenda.xmlのレイアウトとバインドして表示しているっぽいです。
Fragmentは介していないようですね。
AgendaViewModel
class AgendaViewModel @Inject constructor(
loadAgendaUseCase: LoadAgendaUseCase,
getTimeZoneUseCase: GetTimeZoneUseCase
) : ViewModel() {
val loadAgendaResult: LiveData<List<Block>>
private val preferConferenceTimeZoneResult = MutableLiveData<Result<Boolean>>()
val timeZoneId: LiveData<ZoneId>
init {
val showInConferenceTimeZone = preferConferenceTimeZoneResult.map {
(it as? Result.Success<Boolean>)?.data ?: true
}
timeZoneId = showInConferenceTimeZone.map { inConferenceTimeZone ->
if (inConferenceTimeZone) {
TimeUtils.CONFERENCE_TIMEZONE
} else {
ZoneId.systemDefault()
}
}
// Load agenda blocks.
getTimeZoneUseCase(preferConferenceTimeZoneResult)
val observableAgenda = loadAgendaUseCase.observe()
loadAgendaUseCase.execute(Unit)
loadAgendaResult = observableAgenda.map {
(it as? Result.Success)?.data ?: emptyList()
}
}
}
気になったポイント
ioschedアプリでは、基本的にデータ取得用の関数などは作らず、initのブロックでデータを取得しています。
僕らの作っているアプリでは@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
などを使用し、FragmentのLifecycleに合わせてデータ取得用の関数が呼び出されるようにしています。
どうせデータ取得するんだったら、onCreateのタイミングじゃなくてViewModel初期化のタイミングでデータをとっちゃってもいいのかもしれないですね。
所感&まとめ
LiveDataの使い方がとても上手だと感じました。
普通ならRxやCoroutineFlowなどで実装してそうな部分を*「俺はLiveDataだけでも行けるぜ!」*ってな感じで、わかりやすくオシャレに実装してるあたり、やっぱりGoogleってすごいなと。
当たり前ですが、こういう風にデキる人のコードを読むのってとても勉強になりますね!
皆さんも機会があれば、ぜひ一度Google I/Oのコードを読んでみてください!
何か新しい発見があるかも!
以上です!
最後まで読んでいただきありがとうございました!