このライブラリはアルファにあります。 APIはほとんど完成しており、変更されませんが、すべてのプラットフォームでそれを生産するために行うべき作業がまだあります。 JVMとMultiplatformの場合、ライブラリは生産用に使用できますが、iOS、JS、ネイティブの場合、まだ完全にはセットアップされていません。支援したい場合は、お気軽に問題やPRを開いてください。
Redukksは、Kotlin MultiplatformにUniflowおよびReduxのようなアーキテクチャを実装するために必要なシンプルなタイプセーフ抽象化のセットです。店舗、還元剤、アクションの作成を簡素化し、それらをテストする簡単な方法を提供します。
Kotlin/Kotlin Multiplatformには多くのUniflowまたはReduxの実装がありますが、それらのほとんどは複雑すぎるか、単純すぎます。ネーミングが「reduxy」であるかのいずれかで、彼らはあなたに物事を行う1つの方法に強制されるか、抽象化が複雑すぎて定期的に書かれていません。そして、このスタイルに適合するアーキテクチャがありますが、彼らが使用する命名はAndroidに非常に具体的であり、非ローイド開発者に混乱を招くため、他の開発者と推論したり議論したりするのが難しくなります。
私は長年にわたって同じ一連の抽象化を再利用していることに気付いたので、私はそれらを再利用と共有の両方のライブラリにすることにしました。
目標は、Reduxのようなアーキテクチャにシンプルでタイプセーフでテスト可能な抽象化を提供することですが、単一のパターンに完全に結びつけることはなく、独自のソリューションを構築するのに役立つ抽象化を提供します。ほとんどのライブラリは、完全なreduxパターンをプッシュしようとしますが、いくつかの単純なケースの場合、還元剤はオーバーヘッドになる可能性があります。必要に応じて回避できるはずです。そのため、RedukksはReduxパターン自体を強制しません。
なぜUniflowとReduxの理由についてのより多くの理由については、アーキテクチャを構築する講演(de)をチェックできます。
州の管理:Redukksは、状態を管理し、予測可能な方法で更新し、変更を聞くための簡単な方法を提供します。これを使用して、アプリ全体の状態、単一の機能を管理したり、異なる機能(画面)間で共有状態を持っているか、1つの画面に複数の状態を持つことができます。
アクション処理:Redukksは、アクションを処理し、予測可能な方法でアクションを実行する簡単な方法を提供します。これを使用して、UI、ネットワークコール、または必要な他の作業からのアクションを処理できます。ライブラリの感染されていない性質により、USECaseに固有の独自のアクションハンドラーを実装できます。また、逆方向のシグナリングを実装してUniflowパターンを破ることもできます。
テスト:Redukksを使用すると、状態のテスト、削減、アクションは非常に簡単です - APIの性質と追加のコンパイル安全の両方のため。複雑なテストAPIを必要とせずに、状態管理ロジック、またはアクション処理ロジックを簡単にテストできます。
Uniflowアーキテクチャは、1つの単純なアイデアに基づいています。アプリは、一方向に流れるデータの一定のサイクルです。状態とその変更はUIによって推進され、UIは状態によって駆動されます。これにより、単一の真実のソースを持つことができ、アプリについて考える簡単な方法を提供します。
一方、Reduxは、状態を管理するのに役立つパターンです。それは、あなたの状態が単一の真実の源であり、予測可能な方法で更新されるべきであるという考えに基づいています。
Redukksは、これらの2つのアイデアの組み合わせです。ユニフローとReduxベースのアーキテクチャを実装するための基本的な抽象化を提供しますが、物事を行う単一の方法を強制しません。

フロー自体は非常に単純です - 状態Xがあり、それを変更する方法があります。それを変更するこれらの方法は「還元剤」と呼ばれ、それらはその状態を変える唯一の方法です。したがって、どこからでも乱暴に更新する代わりに、状態を更新するために使用できる「更新」のセットを定義します。
しかし、ほとんどのアプリケーションは単純なカウンターよりも複雑であるため、複雑な作業を処理する方法も必要です。それがアクションが始まる場所です。あなたはアクションを「派遣」し、いくつかの作業を実行し、その実行中に、還元剤を介して状態を更新するか、他のアクションを呼び出すことができます。
これにより、状態の変化のすべての可能な順列を簡単に定義およびテストすることができ、アプリの状態について推論し、意図した方法が機能すると確信します。
Gradle Groovy:
dependencies {
implementation ' com.ianrumac.redukks:redukks:0.1.4 '
}Gradle Kotlin:
implementation( " com.ianrumac.redukks:redukks:0.1.4 " ) data class CountState ( val total : Int )
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
}FeatureContextクラスを作成します。 interface CountContext {
val client : CountingAPI
val store : Store < CountState >
}Actionsを定義します sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class AddToClient ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . Add (result))
})
class SubtractFromClient ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . Subtract (result))
})
} data class CountWithAnAPI ( val scope : CoroutineScope ) : CountContext {
override val client = CountingAPI ()
override val store = reducedStore( CountState ( 0 ))
override val handler =
actionHandlerFor(scope, this ) // this is a coroutine scope that will be used to execute the actions
}
val context = CountWithAnAPI (scope)
// In a class that implements Dispatcher<Actions>
// This is actually a call to context.handler.dispatch(Actions.Add(1))
dispatch( Actions . Add ( 1 )) interface CountContext {
val client : CountingAPI
val store : Store < CountState >
val handler : ActionDispatcher < Actions >
data class CountState ( val total : Int )
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
}
sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class AddToClient ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . Add (result))
})
class SubtractFromClient ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . Subtract (result))
})
}
} val context = CountWithAnAPI (scope)
val handler = context.handler
handler.dispatch( Actions . AddToClient ( 1 ))
context.store.listen().collectLatest { state ->
// do something with the state
}AndroidのViewModelを使用してRedukksを簡単に使用できます。JetpackCompose(およびビューフレームワークも!)に適しています。 Stateflowとしてストアを収集するか、Livedataで使用してください。 ViewModelはDispatcher<Actions>を実装することもできます。そのため、アクションを上流にディスパッチできます(アクションハンドラーに委任します)。また、これらのことを処理するReduxViewModelなど、この抽象化を作成することもできます。
ViewModelでそれを使用することは簡単です:
@HiltViewModel
class CountViewModel @Inject constructor(
val actionHandler : Dispatcher < CountContext . Actions >,
val store : Store < CountContext . State >
) : ViewModel(), Dispatcher<Actions> by actionHandler {
// Note: Not a real implementation, be careful where you collect and map your state
val state = store.listen().map( this ::mapToViewModel)
.stateIn(viewModelScope, SharingStarted . WhileSubscribed (), initialValue = CountVM ( 0 ))
fun mapToViewModel ( it : CountContext . State ): CountVM {
.. .
}
}そして今、あなたのUIであなたは単にできることをすることができます:
val vm : CountViewModel by viewModels()
.. .
vm.dispatch( Actions . AddToClient ( 1 ))また、ライフサイクルスコープツリーで上下に巻き上げて、状態のライフサイクルを制御することもできます。たとえば、ステートストアを共有する必要がある場合は、IEのフラグメントスコープからアクティビティスコープに移動して、フラグメントに下向きに移動できます。これにより、フラグメント間で状態を共有できますが、それでも1つの州ストアしかありません。アクションのために複数の店舗を提供したり、UIに複数の店舗を提供して結合することもできます。
typealias CountStore = Store < CountState >
typealias UserStore = Store < UserState >
interface CountContext {
val countStore : CountStore
val userStore : UserStore
}
class AuthenticatedCounterContext ( userStore : UserStore ) {
override val userStore = userStore
override val countStore = CountStore ( CountState ( 0 ))
}
// ...
class AddForUser : Actions ({
val count = countStore.state.total
countStore.update( Updates . Add (1))
userStore.update( UpdateStateForUser (countStore.total))
})
これは、Jetpack Composeで美しく動作します。これは、状態からUIを簡単に導き出すことができるためです(そうする前にビューモデルクラスにマップしてください。コアモデルとUIモデルを混ぜないでください)。

Redukksはモジュール式の抽象化のセットであるため、必要な複雑さレベルに必要な部分のみを使用できます。 Redukksは、抽象化のデフォルトの実装をいくつか提供しますが、簡単に独自のデフォルトを作成できます。
最も簡単なケース、シンプルなストアから始めましょう。簡単なカウンターの実装から始めます。これは、削減やアクションのない店のみを使用します。一般に、還元剤を書きたい場合は、 BasicStoreクラスを使用できます。これは、現在の状態を取得して新しい状態を返す閉鎖を伴うupdate関数を公開する単純なストアです。
data class CountState ( val total : Int )
val store = BasicStore ( CountState ( 0 ))
store.update {
it.copy(it.total + 1 )
}
println (store.state.total) // prints 1
store.listen().collectLatest { state ->
// do something with the state
}これで、還元剤を使用して、タイプフェイの方法で状態の更新を定義することもできます。それでは、還元剤を作成しましょう。還元剤は、 Reducer<StateType>インターフェイスを実装するオブジェクトにすぎません。つまり、現在の状態で閉鎖をreduceし、新しい状態を返すことを意味します。書くようなものです
fun addNumber ( state : CountState , number : Int ) : CountState {
return state.copy(state.total + number)
}しかし、もう少しタイプの安全性があるため、すべての可能な状態の変更が事前に定義され、テストされることを確認できます。
sealed class Updates ( override val reduce : ( CountState ) -> CountState ) : Reducer<CountState> {
class Add ( val number : Int ) : Updates({
state.copy(state.total + number)
})
class Subtract ( val number : Int ) : Updates({
state.copy(state.total - number)
})
}これで、この還元剤を使用するには、 ReducedStore<StateType, ReducerType>を作成する必要があります。これは、定義された還元型タイプを介してのみ更新できるストアです。デフォルトの実装はBasicReducedStoreとして提供されますが、簡単に独自に作成できます。
val basicStore = BasicStore ( CountState ( 0 ))
// you can either provide it with a store or the initial state to use a BasicStore
val store = BasicReducedStore < CountState , Updates >(basicStore)
// or use the helper function
val store = createReducedStore< CountState , Updates >( CountState ( 0 ))
store.update( Updates . Add ( 1 ))
store.listen().collectLatest { state ->
// do something with the state
}アクションは、実行できる非同期コードです。通常、ネットワーク呼び出しなどの副作用を実行したり、他のアクションや更新を派遣したりするために使用されます。たとえば、サーバーからアイテムのリストを取得し、結果に状態を更新するためのアクションを発送します。
通常、アクションは依存関係にアクセスできる必要があるため、もう少し複雑です。これを行うには、すべての依存関係を含む<Context>を提供し、レシーバーとしてコンテキストを使用して実行されるアクションを作成します。つまり、 thisにアクセスできます。これにより、アクションを処理するはずのDispatcher<ActionType>にディスパッチできます。
基本的なCoroutineベースのアクションハンドラーはDispatcher<ActionType> TypedActionHandlerの下にデフォルトで実装され、カスタムAsyncDispatcher<ActionType>使用することで簡単に実装できます。また、コンストラクターを介して閉鎖を単純に通過するために使用できるAsyncActionクラスも提供されています。
それでは、非同期アクションを定義する方法を見てみましょう。
まず、これらのアクションを実行できるコンテキストが必要です。
単純なケースの場合(つまり、ストアを更新するだけ)、 Store<StateType>を使用するだけで、カスタムコンテキストを作成することをお勧めします。これにより、依存関係にアクセスしたり、ock笑したり、交換したりするなどの簡単な方法が得られます。また、アクションをしっかりと結合したままにしておくことをお勧めします。そうすれば、どのアクションが依存関係を必要とするかを簡単に確認できます。
Kotlinのコンテキストレシーバーが途中であるため、コードは将来さらにクリーンになり、アクションの必要性を完全に削除し、レシーバーに単純な関数を実装するだけです。しかし、ほとんどのコードベースにはまだコンテキストレシーバーがないため、今のところこの実装のみに焦点を当てます。
まず、必要な依存関係を含むCountContextインターフェイスを定義します。
interface CountContext {
val client : CountingAPI
val store : Store < CountState >
val handler : Dispatcher < Actions >
}次に、 TypedAction<CountContext> :
sealed class Actions ( override val execute : suspend CountContext .() -> Unit ) : TypedAction<CountContext> {
class Add ( val number : Int ) : Actions({
// here, we have access to all the CountContext properties that are captured during execution
// For example, we can access the store and update it with the result of the network call.
val result = client.add(number)
state.update( Updates . UpdateState (result))
})
class Subtract ( val number : Int ) : Actions({
val result = client.subtract(number)
state.update( Updates . UpdateState (result))
})
}これで、コンテキストを実装し、ディスパッチャーを使用してアクションを実行できます。
data class CountWithAnAPI ( val scope : CoroutineScope ) : CountContext {
override val client = CountingAPI ()
override val store = BasicReducedStore < CountState , Updates >( CountState ( 0 ))
override val handler = createActionHandler(scope, this )
}
// In a class that implements Dispatcher<Actions>, like a viewmodel that delegates it to the handler
// This is actually a call to context.handler.dispatch(Actions.Add(1))
dispatch( Actions . Add ( 1 ))
dispatch( Actions . Subtract ( 1 ))
テストは、還元剤とアクションの両方で非常に簡単です。還元剤の場合、状態と還元剤、つまりval state = CountState(0)およびval reducer = Updates.Add(1)を作成し、 reducer.reduce(state)を呼び出します。これで、結果が予想されるものであると断言できます。
@Test
fun `update the total count by adding 1` () {
val state = CountState ( 0 )
val reducer = Updates . Add ( 1 )
val result = reducer.reduce(state)
assertEquals( 1 , result.total)
}アクションの場合、コンテキストとディスパッチャー、つまりval context = CountWithAnAPI(TestCoroutineScope())およびval action = Action.Add(1)を作成し、 action.execute(context)を呼び出します。これにより、アクションをテストする簡単な方法が提供されます。これは、コンテキストを簡単にock笑し、アクションが期待どおりに実行されるか、Mockkなどのモッキングライブラリを使用して適切に実行されたモックを確認できることを主張できるためです。
@Test
fun `update the total count and add from client` () = runTest {
val context = CountWithAnAPI ( this )
val action = Actions . Add ( 1 )
action.execute(context)
assertEquals( 1 , context.store.state.total)
}各コンポーネントは、簡単にカスタマイズできるようにする方法で記述されているため、簡単に独自に作成できます。意味ストアやディスパッチャーを実装することで、API/Socket/Room Backed Store、またはそれらが完了したときに合図するアクション、またはストア内の他のフローなどのようなものを簡単に作成できます。あなたのニーズに合わせてパターンをカスタマイズすることを恐れないでください - 私たちはパターンを満たすためのコードを書いているのではなく、自分自身、チームメイト、ユーザーのためにそれを書いています。
提案や改善がある場合は、お気軽に問題やPRを開いてください。