Last active
August 19, 2020 14:42
-
-
Save brendanw/0b06ab41fa3e63d7cd2ab7fb055980d5 to your computer and use it in GitHub Desktop.
StateMachine.kt is a 34-line implementation of a Mealy state machine tested for multi-threaded coroutines in kotlin multiplatform. SearchStateMachine.kt is an example usage of the state machine; Search.kt defines the input actions and set of states. For more background, read http://brendanweinstein.com/a-statemachine-for-multithreaded-coroutines…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import com.basebeta.utility.logging.kprint | |
import kotlinx.atomicfu.atomic | |
import kotlinx.coroutines.* | |
import kotlinx.coroutines.channels.BroadcastChannel | |
import kotlinx.coroutines.channels.Channel | |
import kotlinx.coroutines.flow.* | |
/** | |
* Only can use with coroutines 1.3.6-native-mt. See alternative implementation at the bottom for 1.3.5 and under. | |
* | |
* More idiomatic implementation that can land once next native-mt version is published to maven | |
* Relevant fix is here https://github.com/Kotlin/kotlinx.coroutines/commit/87b36106f8b0c649cd435dd02143d9899ed5c75c | |
* See last comment in https://github.com/Kotlin/kotlinx.coroutines/issues/1831 for more on the issue | |
*/ | |
class StateMachine<R : Any, T>( | |
val scope: CoroutineScope, | |
private val initialState: T, | |
private val sideEffects: List<(Flow<R>, () -> T) -> Flow<R>>, | |
private val reducer: suspend (accumulator: T, value: R) -> T | |
) { | |
private val _viewState: ConflatedBroadcastChannel<T> = ConflatedBroadcastChannel(initialState) | |
val viewState = _viewState as Flow<T> | |
private val inputActions: Channel<R> = Channel() | |
init { | |
scope.launch { | |
val lastState = atomic(initialState) //use atomicfu for atomics | |
val multicaster = inputActions.multicast(scope) | |
val flowList = sideEffects.map { sideEffect -> | |
sideEffect(multicaster.asFlow(), { lastState.value }) | |
}.run { | |
toMutableList().apply { | |
add(multicaster.asFlow()) | |
} | |
} | |
flowList.merge().onEach { kprint("result $it") } | |
.onCompletion { inputActions.cancel() } | |
.scan(lastState.value, reducer) | |
.distinctUntilChanged() | |
.collect { outputState -> | |
lastState.value = outputState | |
_viewState.send(outputState) | |
} | |
} | |
} | |
fun dispatchAction(action: R) = scope.launch { | |
kprint("Received input action: $action") | |
inputActions.send(action) | |
} | |
} | |
fun <T> Channel<T>.multicast(scope: CoroutineScope): BroadcastChannel<T> { | |
val channel = this | |
return scope.broadcast { | |
for (x in channel) { | |
send(x) | |
} | |
}.also { | |
it.invokeOnClose { channel.cancel() } | |
} | |
} | |
/* | |
* Stable implementation for coroutines native-mt-1.3.5 and under | |
*/ | |
class StateMachine<R : Any, T>( | |
val scope: CoroutineScope, | |
private val initialState: T, | |
private val sideEffects: List<(Flow<R>, () -> T) -> Flow<R>>, | |
private val reducer: suspend (accumulator: T, value: R) -> T | |
) { | |
val _viewState: ConflatedBroadcastChannel<T> = ConflatedBroadcastChannel(initialState) | |
val viewState: Flow<T> = _viewState as Flow<T> | |
private var isInitialized = atomic(false) | |
private val inputActions: BroadcastChannel<R> = BroadcastChannel(Channel.BUFFERED) | |
init { | |
scope.launch { | |
val lastState = StateWrapper(initialState) | |
val flowList = sideEffects.map { sideEffect -> | |
sideEffect(inputActions.asFlow(), { lastState.state }) | |
}.run { | |
toMutableList().apply { | |
add(inputActions.asFlow()) | |
} | |
} | |
flowList.onBindMerge { isInitialized.value = true } | |
.onEach { kprint("result: $it") } | |
.onCompletion { inputActions.cancel() } | |
.scan(lastState.state, reducer) | |
.distinctUntilChanged() | |
.collect { outputState -> | |
kprint("state emitted: $outputState") | |
lastState.state = outputState | |
viewState.send(outputState) | |
} | |
} | |
} | |
fun dispatchAction(action: R) = scope.launch { | |
kprint("Received input action $action") | |
while (!isInitialized.value) { | |
yield() | |
} | |
inputActions.send(action) | |
} | |
} | |
/** | |
* Ensures that every down stream flow is bound to the upstream | |
*/ | |
fun <T> List<Flow<T>>.onBindMerge(onBind: () -> Unit): Flow<T> { | |
var boundFlows = atomic(0) | |
return channelFlow { | |
forEach { flow -> | |
launch { | |
flow.onStart { | |
if (boundFlows.incrementAndGet() == size) onBind() | |
}.collect { | |
send(it) | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* iOS-specific ViewModel that lives in iosMain of common module | |
*/ | |
class KSearchViewModel( | |
exitDb: KExitDatabase | |
) : CoroutineScope { | |
private val job = Job() | |
override val coroutineContext: CoroutineContext = dispatcher() + job | |
@InternalCoroutinesApi | |
private val searchStateMachine = SearchStateMachine(this, exitDb) | |
val cStateFlow = searchStateMachine.viewState.wrap() | |
fun dispatchAction(action: Search.Action) { | |
searchStateMachine.dispatchAction.invoke(action) | |
} | |
@UseExperimental(ExperimentalCoroutinesApi::class) | |
fun clear() { | |
job.cancel() | |
} | |
} | |
fun <T> ConflatedBroadcastChannel<T>.wrap(): CFlow<T> = CFlow(asFlow()) | |
fun <T> Flow<T>.wrap(): CFlow<T> = CFlow(this) | |
class CFlow<T>(private val origin: Flow<T>) : Flow<T> by origin { | |
fun watch(block: (T) -> Unit): Closeable { | |
val job = Job() | |
onEach { | |
block(it) | |
}.launchIn(CoroutineScope(Dispatchers.Main + job)) | |
return object : Closeable { | |
override fun close() { | |
job.cancel() | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Define a finite set of input actions and a finite set of output view states. | |
* | |
* This lives in a common module that both the android and iOS common modules depend on. | |
*/ | |
class Search { | |
/** | |
* Defines default options for each filter section | |
*/ | |
data class FilterOptions( | |
val filterCountryList: List<String> = defaultCountryList, | |
val filterExitDirectionList: List<String> = exitDirectionList | |
) | |
sealed class Action { | |
object InitializeFiltersAction : Search.Action() | |
data class FilterOptionsLoadedAction(val filterOptions: FilterOptions) : Search.Action() | |
object TapFilterCancelBtn : Search.Action() | |
object TapOpenFilterIcon : Search.Action() | |
object BackButtonTapAction : Search.Action() | |
data class QueryChangeAction( | |
val filterState: FilterState, | |
val query: String | |
) : Search.Action() | |
data class FilterUpdateAction( | |
val filterState: FilterState, | |
val query: String | |
) : Search.Action() | |
data class SearchLoadedAction( | |
val items: List<SearchResult>, | |
val diffResult: DiffResult? | |
) : Search.Action() | |
} | |
enum class StateType { | |
InitialState, | |
FilterOptionsLoaded, | |
ShowResultsState, | |
CloseFilterState, | |
ClosePageState, | |
OpenFilterState | |
} | |
/* | |
* StateType is our workaround to not wanting to redefine copy-able properties for each class | |
* definition within a sealed class. | |
*/ | |
data class State( | |
val type: StateType, | |
val items: List<SearchResult>, | |
val diffResult: DiffResult?, | |
val isFilterWindowVisible: Boolean = false, | |
val filterOptions: FilterOptions = FilterOptions() | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* SearchActivity on android that renders states emitted by the StateMachine | |
*/ | |
class SearchActivity : BaseBetaActivity() { | |
companion object { | |
fun launch(activity: Activity) { | |
val intent = Intent(activity, SearchActivity::class.java) | |
activity.startActivity(intent) | |
} | |
} | |
private val filterView: FilterView by lazy { FilterView(this@SearchActivity) } | |
private val adapter: SearchAdapter = SearchAdapter(items = emptyList()) | |
private lateinit var viewModel: SearchViewModel | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.search) | |
viewModel = ViewModelProviders.of( | |
this, | |
SearchViewModel.SearchViewModelFactory( | |
application | |
) | |
).get(SearchViewModel::class.java) | |
initViews() | |
lifecycleScope.launch { | |
viewModel.viewState.collect { state -> | |
render(state) | |
} | |
} | |
} | |
override fun onBackPressed() { | |
viewModel.dispatchAction(Search.Action.BackButtonTapAction) | |
} | |
private fun render(state: Search.State) { | |
when(state.type) { | |
Search.StateType.InitialState -> { | |
showInstructionsView() | |
} | |
Search.StateType.FilterOptionsLoaded -> { | |
filterView.updateCountryList(state.filterOptions.filterCountryList) | |
} | |
Search.StateType.ShowResultsState -> { | |
adapter.setItems(state.items) | |
state.diffResult!!.dispatchUpdatesTo(adapter) | |
search_results_list.visibility = View.VISIBLE | |
instructions.visibility = View.GONE | |
} | |
Search.StateType.OpenFilterState -> { | |
filterView.open() | |
} | |
Search.StateType.CloseFilterState -> { | |
filterView.close() | |
} | |
Search.StateType.ClosePageState -> { | |
finish() | |
} | |
} | |
} | |
private fun initViews() { | |
filterView.attachViewModel(viewModel) | |
filterView.showSortBySection(true) | |
search_container.addView(filterView, | |
FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, | |
ViewGroup.LayoutParams.MATCH_PARENT)) | |
search_input.background.setColorFilter(resources.getColor(R.color.off_white), PorterDuff.Mode.SRC_IN) | |
val context = this | |
val layoutManager = LinearLayoutManager(context) | |
search_results_list.layoutManager = layoutManager | |
search_results_list.adapter = adapter | |
search_results_list.addItemDecoration(DividerItemDecoration(context, | |
layoutManager.orientation)) | |
search_results_list.addOnItemTouchListener(RecyclerItemClickListener(context, object : RecyclerItemClickListener.OnItemClickListener { | |
override fun onItemClick(childView: View, position: Int) { | |
ExitActivity.launch(this@SearchActivity, adapter.getItem(position)._id) | |
} | |
override fun onItemLongPress(childView: View, position: Int) { } | |
})) | |
back_btn.setOnClickListener { viewModel.dispatchAction(Search.Action.BackButtonTapAction) } | |
filter_icon.setOnClickListener { | |
this.closeKeyboard() | |
viewModel.dispatchAction(Search.Action.TapOpenFilterIcon) | |
} | |
search_input.addTextChangedListener(object : TextWatcher { | |
override fun beforeTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) { } | |
override fun onTextChanged(charSequence: CharSequence, i: Int, i1: Int, i2: Int) {} | |
override fun afterTextChanged(editable: Editable) { | |
viewModel.dispatchAction( | |
Search.Action.QueryChangeAction( | |
filterState = filterView.generateFilterState(), | |
query = editable.toString() | |
) | |
) | |
} | |
}) | |
} | |
private fun showInstructionsView() { | |
progress_bar.visibility = View.GONE | |
instructions.visibility = View.VISIBLE | |
search_results_list.visibility = View.GONE | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Sample usage for screen with search bar at the top, a filter icon that launches a modal/dialog with advanced filter | |
* options, and displays results as a list. | |
* | |
* This lives in a common module that both the android and iOS common modules depend on. | |
*/ | |
class SearchStateMachine( | |
scope: CoroutineScope, | |
private val exitDb: KExitDatabase | |
) { | |
private val stateMachine = StateMachine( | |
scope = scope, | |
initialState = Search.State(Search.StateType.InitialState, emptyList(), null), | |
reducer = ::reducer, | |
sideEffects = listOf( | |
::generateFilterOptions, | |
::queryDatabase, | |
::filterDatabase | |
) | |
) | |
val viewState = stateMachine.viewState | |
val dispatchAction = stateMachine::dispatchAction | |
// Needed for android so we can generate diffResult. | |
var lastList: AtomicRef<List<SearchResult>> = atomic(listOf()) | |
private fun generateFilterOptions(input: Flow<Search.Action>, state: () -> Search.State): Flow<Search.Action> = | |
input.filterIsInstance<Search.Action.InitializeFiltersAction>() | |
.map { | |
val defaultOptions = state().filterOptions | |
val filterOptions = withContext(Dispatchers.Default) { | |
val countryList = exitDb.exitEntityQueries.listAllCountries().executeAsList() | |
defaultOptions.copy(filterCountryList = countryList) | |
} | |
Search.Action.FilterOptionsLoadedAction(filterOptions = filterOptions) | |
} | |
private fun filterDatabase(input: Flow<Search.Action>, state: () -> Search.State): Flow<Search.Action> = | |
input.filterIsInstance<Search.Action.FilterUpdateAction>() | |
.map { action -> | |
performFilterSearch(action.filterState, action.query) | |
} | |
private fun queryDatabase(input: Flow<Search.Action>, state: () -> Search.State): Flow<Search.Action> = | |
input.filterIsInstance<Search.Action.QueryChangeAction>() | |
.map { action -> | |
performQuerySearch(action.filterState, action.query) | |
} | |
private suspend fun performFilterSearch(filterState: FilterState, query: String) = withContext(Dispatchers.Default) { | |
val searchResultList = KSearchQueryExecutor(exitDb).queryDatabase(filterState, query) | |
val prevList = lastList.value | |
val diffResult = KDiffUtil.calculateDiff( | |
SearchResultItemDiffHelper( | |
newList = searchResultList, | |
oldList = prevList | |
) | |
) | |
Search.Action.SearchLoadedAction(searchResultList, diffResult) | |
} | |
private suspend fun performQuerySearch(filterState: FilterState, query: String) = withContext(Dispatchers.Default) { | |
val newList = KSearchQueryExecutor(exitDb).queryByName(filterState, query) | |
val prevList = lastList.value | |
val diffResult = KDiffUtil.calculateDiff( | |
SearchResultItemDiffHelper( | |
newList = newList, | |
oldList = prevList | |
) | |
) | |
Search.Action.SearchLoadedAction(newList, diffResult) | |
} | |
private suspend fun reducer(state: Search.State, action: Search.Action): Search.State { | |
//kprint("reducer: curState=$state action=$action") | |
return when (action) { | |
is Search.Action.InitializeFiltersAction -> state | |
is Search.Action.FilterOptionsLoadedAction -> { | |
state.copy(type = Search.StateType.FilterOptionsLoaded, filterOptions = action.filterOptions) | |
} | |
is Search.Action.TapFilterCancelBtn -> state.copy( | |
type = Search.StateType.CloseFilterState, | |
isFilterWindowVisible = false | |
) | |
is Search.Action.TapOpenFilterIcon -> { | |
kprint("returning OpenFilterState") | |
state.copy(type = Search.StateType.OpenFilterState, isFilterWindowVisible = true) | |
} | |
is Search.Action.QueryChangeAction -> state | |
is Search.Action.BackButtonTapAction -> { | |
if (state.isFilterWindowVisible) { | |
state.copy(type = Search.StateType.CloseFilterState, isFilterWindowVisible = false) | |
} else { | |
state.copy(type = Search.StateType.ClosePageState, isFilterWindowVisible = false) | |
} | |
} | |
is Search.Action.FilterUpdateAction -> state.copy( | |
type = Search.StateType.CloseFilterState, | |
isFilterWindowVisible = false | |
) | |
is Search.Action.SearchLoadedAction -> { | |
lastList.value = action.items | |
state.copy(type = Search.StateType.ShowResultsState, items = action.items, diffResult = action.diffResult) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* SearchViewModel on iOS that renders states emitted by the state machine. | |
* This code lives in the iOS project. | |
*/ | |
class SearchViewController: UIViewController, UISearchBarDelegate, UITableViewDelegate, UITableViewDataSource { | |
private let reuseIdentifier = "SearchTableViewCell" | |
private var viewModel: KSearchViewModel? = nil | |
var filterViewController: FilterViewController? = nil | |
var tableView: UITableView! | |
var searchBar: UISearchBar! | |
var searchResults: [SearchResult] = [] | |
var searchActive: Bool = false | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
analytics.trackEvent(AE.VIEW_SEARCH) | |
filterViewController = FilterViewController() | |
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: self.makeFilterButton()) | |
tableView = UITableView() | |
tableView.register(SearchTableViewCell.self, forCellReuseIdentifier: "searchTableViewCell") | |
searchBar = UISearchBar() | |
view.addSubview(tableView) | |
tableView.frame = view.frame | |
navigationItem.titleView = searchBar | |
searchBar.translatesAutoresizingMaskIntoConstraints = false | |
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor) | |
searchBar.topAnchor.constraint(equalTo: view.topAnchor) | |
applyBlueTheme() | |
searchBar.delegate = self | |
tableView.dataSource = self | |
tableView.delegate = self | |
self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: makeBackButton()) | |
viewModel = KSearchViewModel(exitDb: kotlinDb) | |
viewModel!.cStateFlow.watch { state in | |
self.render(state: state!) | |
} | |
filterViewController?.attachViewModel(viewModel: viewModel!) | |
} | |
func releaseReferences() { | |
viewModel?.clear() | |
filterViewController = nil | |
viewModel = nil | |
} | |
func render(state: Search.State) { | |
switch(state.type) { | |
case .initialstate: | |
print("initial state") | |
case .filteroptionsloaded: | |
filterViewController?.setCountryList(countryList: state.filterOptions.filterCountryList) | |
case .closefilterstate: | |
print("close filter state") | |
filterViewController?.dismiss(animated: true, completion: nil) | |
case .openfilterstate: | |
print("open filter test") | |
filterViewController?.attachViewModel(viewModel: viewModel!) | |
guard let controller = filterViewController else { return } | |
controller.modalPresentationStyle = .fullScreen | |
self.present(controller, animated: true, completion: nil) | |
case .showresultsstate: | |
print("show results state") | |
self.searchResults = state.items | |
self.tableView.reloadData() | |
case .closepagestate: | |
print("close page state") | |
default: | |
print("should never get here") | |
} | |
} | |
func makeFilterButton() -> UIButton { | |
let backButtonImage = UIImage(named: "backbutton")?.withRenderingMode(.alwaysTemplate) | |
let filterBtn = UIButton(type: .custom) | |
filterBtn.setImage(backButtonImage, for: .normal) | |
filterBtn.tintColor = UIColor(named: "MainText") | |
filterBtn.setTitle("Filter", for: .normal) | |
filterBtn.setTitleColor(UIColor(named: "MainText"), for: .normal) | |
filterBtn.addTarget(self, action: #selector(dispatchOpenFilter), for: .touchUpInside) | |
return filterBtn | |
} | |
@objc func dispatchOpenFilter() { | |
self.viewModel!.dispatchAction(action: Search.ActionTapOpenFilterIcon()) | |
} | |
@objc func backButtonPressed() { | |
dismiss(animated: true, completion: nil) | |
releaseReferences() | |
} | |
override func viewDidAppear(_ animated: Bool) { | |
self.tableView.reloadData() | |
} | |
func numberOfSectionsInTableView(tableView: UITableView) -> Int { | |
return 1; | |
} | |
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | |
return searchResults.count; | |
} | |
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | |
let cell = tableView.dequeueReusableCell(withIdentifier: "searchTableViewCell", for: indexPath) as! SearchTableViewCell | |
let currExit = self.searchResults[indexPath.row] | |
cell.descriptionLabel.attributedText = makeExitListText(exit: currExit) | |
return cell | |
} | |
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | |
tableView.deselectRow(at: indexPath, animated: true) | |
let controller = ExitViewController() | |
let currExit = self.searchResults[indexPath.row] | |
//controller.exit = allExits[currExit.name] as! Exit! | |
controller.exitId = currExit._id | |
let uiNavController = UINavigationController(rootViewController: controller) | |
uiNavController.modalPresentationStyle = .fullScreen | |
self.present(uiNavController, animated: true, completion: nil) | |
} | |
func populateResults(_ exitList: [SearchResult]) { | |
searchResults = exitList | |
self.tableView.reloadData() | |
} | |
func searchBar(_ searchBar: UISearchBar, | |
textDidChange searchText: String) { | |
self.searchResults.removeAll() | |
viewModel?.dispatchAction( | |
action: Search.ActionQueryChangeAction( | |
filterState: filterViewController!.generateFilterState(), | |
query: searchText | |
) | |
) | |
self.tableView.reloadData() | |
} | |
override var prefersStatusBarHidden: Bool { | |
return true | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Android-specific ViewModel for handling configuration changes. | |
*/ | |
class SearchViewModel( | |
app: Application = App.instance, | |
) : AndroidViewModel(app) { | |
private val searchStateMachine = SearchStateMachine(viewModelScope, App.dbHelper.dbRef) | |
val viewState = searchStateMachine.viewState | |
fun dispatchAction(action: Search.Action) { | |
searchStateMachine.dispatchAction.invoke(action) | |
} | |
class SearchViewModelFactory( | |
private val app: Application | |
) : ViewModelProvider.Factory { | |
@Suppress("UNCHECKED_CAST") | |
override fun <T : ViewModel?> create(modelClass: Class<T>): T { | |
return SearchViewModel(app = app) as T | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment