はじめに
この記事は、Android#2のアドベントカレンダーの17日目の記事になります。
The Chain MuseumでAndroidエンジニアをしているYoung (@akihito-okada) です。
現在私の関わっているArtStickerというアプリでは、以下の構成で開発をしています。
Flux
Single Activity
-
Multiple Back Stacks
(BottomNavigationViewを各画面に持ち、メニュー毎にスタックを管理)
今回は、この構成における画面毎のデータ管理の方法について紹介したいと思います。
Multiple Back Stacksについて
ArtStickerにおける画面遷移の要件として、アプリ内のほとんどの画面でBottomNavigationViewを表示しつつ、各メニューごとにスタックを管理する
ことがあげられます。ここで、BottomNavigationViewの各メニューごとにスタック管理する機能のことを、Multiple Back Stacksと呼びます。
Multiple Back Stacksにより、ユーザは少しだけマルチタスク的にタブ間を行き来することができるようになったり、遷移が途切れることなくアプリを利用できるようになるため便利になります。
スタックの管理を考えるとき、複数Activity
で画面を構成すれば、画面遷移をしてもViewは維持されるためシンプルに実装することができます。
このため、各メニューのTopページをFragmentで管理し、そこからの画面遷移はActivityで遷移するとする実装が多いように思います。
ただ、アプリ内のほとんどの画面でBottomNavigationViewを表示する必要があるとき、Activity毎にBottomNavigationViewが必要になり、良い構成とはいえなくなります。
そこで、ActivityにBottomNavigationViewを置き、Fragmentで画面遷移を行う、Single Activity構成とすることで、各画面にBottomNavigationViewを置くことができるようになります。
Single Activityにおける、Fragmentによる画面遷移の管理について、Navigation Component
を使用し、開発をすすめることも検討しましたが、現在はMultiple Back Stacksは対応していません (2019/12現在、開発中のようです)。
このため、ncapdevi/FragNavというライブラリを用い開発を行いました。
Fluxについて
次に、Fluxについて簡単に説明します。
Fluxは単一フローの中でデータを管理するアーキテクチャです。
Fluxの主な登場人物としては以下のものがあります。
-
View
- ActivityやFragmentにあたります
-
Store
から取得したデータをもとに、画面要素を表示します
-
ActionCreator
-
View
のアクションなどをもとに、Repositoryなどに直接アクセスし、APIやShareredPreferencesから取得したデータをDispatcherに渡す役割を持ちます
-
-
Action
-
ActionCreator
から、Dispatcher
に渡すデータバリエーションの定義を持ちます
-
-
Dispatcher
-
ActionCreator
からの呼び出しを受けて、Action
をStore
に渡します
-
-
Store
-
Dispatcher
から必要なデータを受け取り、自身のデータかどうかを検証し、View
に表示するためのデータを保持します
-
MVVMと似ている部分もありますが、APIなどのデータにアクセスする処理(ActionCreator)と、Viewで使用するデータを保持する処理(Store)をデータフローの中で分離できることが特徴といえると思います。
ただ、DispatcherからStoreに対してBroadcastのようにデータを渡すため、Store、及びViewで機能的な要件やViewのLifecycleに応じて、適切にデータをハンドリングすることが求められます。
Fluxは、ここ数年Androidのプロダクトでも取り入れるケースが増えてきているのではないかと思います。
また、DroidKaigi 2019 のアプリでもFluxが採用され話題となりました。
ArtStickerでもDroidKaigi 2019
のFlux構成をベースに開発を進めました。
SNSなアプリにおけるFluxについて
ArtStickerは、以下のような、いわゆるSNSな機能を持ちます。
- HomeやみつけるなどのSNSによくあるリスト画面
- コンテンツ(アート)の詳細画面
- コンテンツを提供する人(アーティスト)の詳細画面
- 一般ユーザーの詳細画面
- 一般ユーザーの管理画面
また、コンテンツに対するいいね
や、一般ユーザーへのフォロー
などの機能を持ちます。
このようなアプリにおいて、ユーザーの詳細画面のように同じIDを持つ同じ画面が複数存在する
ため、自然な画面遷移を実現するために、同じIDを持つ同じ画面
毎に画面固有のリストやスクロール位置を保持する必要があります。
例えば、画面固有のリストを共通で管理する
とし、以下のようなケースを考えます。
- ユーザー詳細画面(A')でリストを読み込みと追加読み込みを行う
- ユーザー詳細画面(A'')に遷移し、リストを再読込をし、追加読み込みはしない
- ユーザー詳細画面(A')に戻る
この時、A'
に追加読み込み分のデータがないと、A'
からA''
にどのように遷移をしたかといった情報が失われてしまいます。
よって、アプリの機能的な要件をふまえると、以下のような観点で制御が必要であることがわかります。
- アプリ全体に反映させたいデータ(
いいね
やフォロー
などのデータ) - 画面遷移毎に閉じて保持したいデータ(リストなどのデータ)
また、以下のような制御も重要になります。
- 一度だけ受け取りたい(画面遷移して戻ってきたときは、受け取りたくない)
- 何度も受け取りたい(画面遷移して戻ってきた時も受け取りたい)
例えば、3のデータの制御ができないと、画面遷移のイベントを受け取る時、画面遷移して戻ったあとにすぐにまた画面遷移してしまったりします。
画面毎のデータ管理の実装
ここからは、具体的な実装について紹介したいと思います。
使用している主なライブラリ
DroidKaigi 2019をベースとしていることもあり、以下のような技術を利用しています。
-
Kotlin Coroutines
- Action Creatorの中でAPIのアクセスをするときに利用しています
- Action CreatorからDispatcherを経由してStoreにデータを流すときに利用しています
-
ViewModel
/LiveData
- StoreはJetPackのViewModelを継承し、ViewからStoreへのデータのアクセスはLiveDataを用います
- この恩恵として、例えば、Fragmentで画面遷移をして戻ってきたときに、StoreのLiveDataを通して保持していたデータで画面を復元することができます
-
Dagger
- アプリ全体のインスタンスの生成管理に利用しています
画面毎に保持したいデータとアプリ全体に反映させたいデータの制御
まず、基本的な構成としてFragment毎に、ActionCreator、及びStoreを作ります。
ユーザー詳細画面であれば以下のように3つのクラスから構成します。
- UserDetailFragment
- UserDetailActionCreator
- UserDetailStore
続いて、Action
の分け方ですが、アプリ全体で共通のAction以外は、Fragment毎にActionを定義します。
例えば以下のように、画面毎の読込中の状態を管理するActionを、画面毎に別のActionとして定義します。
これにより、Fragmen単位でActionを閉じられるため、実装がシンプルになるとともに、実装漏れ等による思わぬバグを回避することができます。
sealed class Action {
// User Detail
class UserDetailLoadingStateChanged(val loadingState: LoadingState) : Action()
// 中略
// Artist Detail
class ArtistDetailLoadingStateChanged(val loadingState: LoadingState) : Action()
}
また、同じFragmentでも別の画面
としてデータを管理するために、TagId
というClassを作ります。
data class TagId(var value: Int = 0)
このclassのvalue
には、管理したい画面ごとの固有のIDを設定します。
以下のように、TagIdはFragment単位でDaggerを用いて初期化を行います。(ここではDaggerのandroid拡張を用いています)
@Module
interface MainActivityFragmentBuildersModule {
@FragmentScope
@ContributesAndroidInjector(modules = [UserDetailFragmentModule::class])
fun contributeUserDetailFragment(): UserDetailFragment
}
@Module
abstract class UserDetailFragmentModule {
@Module
companion object {
// 中略
@JvmStatic
@Provides
@FragmentScope
fun providesTagId(userDetailFragment: UserDetailFragment): TagId {
return TagId(userDetailFragment.tag.hashCode())
}
}
}
上記コードでは、Fragmentにセットされたtag
からHashCodeを取得しています。
これは、FragNavで以下のように、Fragmentにtagを設定しており、Fragmentに固有のIDとなっているためです。
fragmentTransaction.replace(containerId, fragment, generateTag(fragment))
private fun generateTag(fragment: Fragment): String {
return fragment.javaClass.name + ++tagCount
}
次に、FragmentでActionCreatorとStoreを初期化します
class UserDetailFragment : DaggerFragment() {
@Inject
lateinit var actionCreator: UserDetailActionCreator
@Inject
lateinit var userDetailStoreProvider: Provider<UserDetailStore>
private val userDetailStore by viewModel { userDetailStoreProvider.get() }
// 中略
}
次に、ActionCreatorのコードです。TagIdを引数に入れています。
@FragmentScope
class UserDetailActionCreator @Inject constructor(
override val dispatcher: Dispatcher,
@FragmentScope lifecycle: Lifecycle,
override var tagId: TagId,
// 中略
) : CoroutineScope by lifecycle.coroutineScope,
TaggedDispatcherHandler {
// 中略
}
続いてActionCreatorで実装しているTaggedDispatcherHandler
になります。ActionにTagIdを設定しつつDispatcherに渡します。
interface TaggedDispatcherHandler {
val dispatcher: Dispatcher
var tagId: TagId
fun launchAndDispatch(action: Action) {
action.tagId = tagId
dispatcher.launchAndDispatch(action)
}
suspend fun dispatch(action: Action) {
action.tagId = tagId
dispatcher.dispatch(action)
}
}
ここで、ActionにTagIdをセットできるようにしています。
sealed class Action {
var tagId: TagId = TagId()
// User Detail
class UserDetailLoadingStateChanged(val loadingState: LoadingState) : Action()
// 中略
}
次にStoreのコードです。TagIdを引数にとり、Dispatcherからイベントを受け取るときに、TagIdが一致しているかチェックします。
class UserDetailStore @Inject constructor(
val dispatcher: Dispatcher,
private val tagId: TagId
) : Store() {
val loadingStateChanged: LiveData<LoadingState> = dispatcher
.subscribe<Action.UserDetailLoadingStateChanged>()
.filter { it.tagId == tagId }
.map { action ->
// 中略
}
.toLiveData(this, null)
}
このように、 filter { it.tagId == tagId }
でチェックするかどうかで、画面毎に保持したいデータとアプリ全体に反映させたいデータの制御ができるようになります。
一度だけ受け取りたいデータと、何度も受け取りたいデータの制御
例として、LiveDataで以下のようにStoreからデータを受け取ることを考えます。
store.loadingStateChanged.observe(viewLifecycleOwner, Observer {
// 中略
})
LiveDataはFragmentで遷移して戻ってくるときに、最後の状態をリストアする動作を行います。通常の動作はこれで良いのですが、すでにデータの反映が済んでいるときや、画面遷移のイベントなどは、受け取る必要はありません。
この制御のために、 android/architecture-samples の Eventクラス を用い、toLiveDataの実装を変更します。
@MainThread
fun <T> Flow<T>.toEventLiveData(
store: Store,
defaultValue: T? = null
): LiveData<Event<T>> {
return object : LiveData<Event<T>>(), CoroutineScope by GlobalScope {
init {
if (defaultValue != null) {
value = Event(defaultValue)
}
val job = launch(CoroutinePlugin.mainDispatcher) {
collect { element ->
postValue(Event(element))
}
}
store.addHook { job.cancel() }
}
}
}
これにより、一度だけイベントを受け取れるようになります。
class UserDetailStore @Inject constructor(
val dispatcher: Dispatcher,
private val tagId: TagId
) : Store() {
val loadingStateChanged: LiveData<Event<LoadingState>> = dispatcher
.subscribe<Action.UserDetailLoadingStateChanged>()
.filter { it.tagId == tagId }
.map { it.loadingState }
.toEventLiveData(this, null)
}
store.loadingStateChanged.observe(viewLifecycleOwner, EventObserver {
// FollowのViewの実装
})
以上で、画面遷移をして状態をリストアするときに、受け取りたくないデータの制御ができるようになります。
参考までに、ここではDispatcherの内部実装をFlowに置き換えています。
@Singleton
class Dispatcher @Inject constructor() {
private val _actions = BroadcastChannel<Action>(Channel.CONFLATED)
val events get() = _actions.asFlow()
inline fun <reified T : Action> subscribe(): Flow<T> {
return events.filterAndCast()
}
suspend fun dispatch(action: Action) {
withContext(CoroutinePlugin.mainDispatcher) {
_actions.send(action)
}
}
fun launchAndDispatch(action: Action) {
GlobalScope.launch(CoroutinePlugin.mainDispatcher) {
_actions.send(action)
}
}
inline fun <reified E, reified R : E> Flow<E>.filterAndCast(
context: CoroutineContext = Dispatchers.Unconfined
): Flow<R> =
channelFlow {
collect { e ->
(e as? R)?.let { r ->
offer(r)
}
}
}.flowOn(context)
}
さいごに
今回は、ArtStickerでの、Flux + Multiple Back Stacks + Single Activityな構成での画面毎のデータ管理の方法についての紹介でした。
もし、理解が間違っていたり、もっと良い方法があるという場合は教えていただけると嬉しいです。