Skip to content

Instantly share code, notes, and snippets.

@brendanw
Last active August 19, 2020 14:42
Show Gist options
  • Save brendanw/0b06ab41fa3e63d7cd2ab7fb055980d5 to your computer and use it in GitHub Desktop.
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…
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)
}
}
}
}
}
/*
* 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()
}
}
}
}
/**
* 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()
)
}
/*
* 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
}
}
/**
* 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)
}
}
}
}
/*
* 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
}
}
/**
* 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