Skip to content

Instantly share code, notes, and snippets.

@michaelzengke
Last active March 6, 2023 15:09
Show Gist options
  • Save michaelzengke/44ac57032f7d877203a0935c99f5ed59 to your computer and use it in GitHub Desktop.
Save michaelzengke/44ac57032f7d877203a0935c99f5ed59 to your computer and use it in GitHub Desktop.

ADDS (Action Delegate Delegatee viewState) — A Kotlin Coroutines Based Android Architecture Pattern

Author: Michael Zeng

Contents

Introduction

ADDS is a new Android app architecture pattern that models product requirements, a.k.a. business logics, as action and delegate. Action models business logics that a worker, which performs the action, can accomplish by it alone. On the other hand, delegate models business logics that a worker is unable to completely fulfill, and needs delegatee to take care of the delegate and complete the business logics.

Based on action, delegate, and delegatee, complicated product requirements can be divided into multiple features. Each feature is fine-grained in terms of the business logics it is supposed to accomplish. Conceptually, the features are constructed in a tree manner, that one feature can be parent feature of other features. Whenever parent-child relationship between features is established, the parent feature serves as its child features' delegatee.

Moreover, a feature needs to conduct two-way communication with view widgets. From the feature to the view widgets, it's the feature updates the view widgets. Reversely, from the view widgets to the feature, it's the view widgets notify the feature of UI interactions. In the ADDS pattern, feature updating view widgets directly leverages Android data binding. However, that the view widgets notifying feature of UI interactions is formalized, again, with the concept of delegate. A viewState is therefore introduced that takes care of the two-way communication between the feature and the view widgets. The viewState can be bound to an instance of data binding class via e.g. setVariable. ViewState can expose convenience methods for the feature to update the view widgets. More importantly, the viewState maps UI interaction, notified by the view widgets, to delegate. Typically, it is the feature that serves as delegatee to the viewState, therefore it is the feature that accepts and completes the delegate.

Below figure illustrates from left to right that a user interacts with view widgets, the UI interaction is mapped to delegate by the viewState, the delegate is accepted by a feature that is the delegatee of the viewState. The feature maps the delegate to an action, performs the action, and finally updates the view widgets via the viewState.

image

As a brief summary, ADDS pattern facilitates using feature to model product business logics to some fine-grained extent. A feature can perform a set of predefined actions, typically leveraging its internal action workers. Also a feature can serve as delegatee to its child features and to its viewStates. A chain of delegatee can be established in the tree of features. And a feature can accept a delegate by two means. Either it maps the delegate to an action that the feature itself can perform hence complete the delegate, or it passes (potentially transformes as well) the delegate up the delegatee chain while somewhere along the chain a delegatee shall be able to accept and actually complete the delegate.

Kotlin Coroutines Basics

In the above Introduction section, we have actually described a unidirectional data flow pattern. However, what's more interesting is the imperative paradigm. When talking about how the feature accomplishes business logics, we described exactly the same way as if it is human language expressing product requirements.

Before we get to more technical details, and code, of the ADDS pattern, let's first quickly review key technology building block that makes all these possible. That is the Kotlin Coroutines.

Kotlin coroutines are light-weight cooperative threads. However, they are actually not the Thread as we are familiar with in Java. Coroutines provide a way to run asynchronous code without having to block threads. Under the hood, coroutines are implemented as suspending functions. It is the suspending functions that allow us to turn callbacks into suspension points that don’t break the apparent control flow. What this really means is that the business logic as described in human language, which is intrinsically asynchronous, can be expressed as "seemingly" synchronous imperative code that are more readable and manageable.

Further, cancellable coroutine context, a unique characteristic of Kotlin coroutines, makes the coroutines especially compatible and useful to building Android apps. Coroutine can start child coroutines, however all the coroutines can be subject to a single cancellable job. Cancelling this job effectively cancels all the coroutine contexts that the entire coroutines parent-child group runs inside. This should sound familiar to anyone heard of LifecycleOwner and LiveData. LiveData leverages LifecycleOwner to stop data stream delivery, reactively. On the contrary, cancellable coroutine context stops suspending functions from being resumed and executed further, imperatively.

View State

Without more introduction and backgrounds, it is time to show the code.

Let's start with viewState. First, the declaration of delegatee. A delegatee returns true to the sender of delegate if the delegatee accepts the delegate.

interface ViewInteractionDelegatee { 
    /**
     * @return true if this delegatee accepts the delegate
     */
    @MainThread
    fun accept(delegate: ViewDelegate): Boolean = false
}

A viewState expects to hook up with a delegatee such that the viewState can try to deliver delegate to the delegatee.

interface InteractiveViewState {
    var viewInteractionDelegatee: ViewInteractionDelegatee?

    /**
     * deliver delegate to a delegatee
     * @return true as long as the viewInteractionDelegatee accepts the delegate
     * @throws UndeliveredException
     */
    @MainThread
    fun deliver(delegate: ViewDelegate): Boolean = viewInteractionDelegatee?.accept(delegate) ?: false

    fun ViewDelegate.deliver() {
        deliver(this)
    }
}

Next is a sample instantiation of viewState. The sample class Std(standard)ClickableTextVS(viewState) offers standard implementation of any clickable textView. This viewState contains a default OnClickListener that can be bound to textView via data binding. The default OnClickListener maps the "click" UI interaction to a delegate, namely the ClickableTextViewClickedDelegate, and requests to deliver the delegate. It's then the delegatee of the viewState at runtime accepts the delegate and conducts business logics.

abstract class StdClickableTextVS : InteractiveViewState {
    val listener: ObservableField<View.OnClickListener> = ObservableField(View.OnClickListener { view ->
        ClickableTextViewClickedDelegate(this).deliver()
    })

    override fun deliver(delegate: ViewDelegate): Boolean {
        val delivered = super.deliver(delegate)
        return when (delegate) {
            is ClickableTextViewClickedDelegate -> delivered || throw UndeliveredException(delegate)
            else -> delivered
        }
    }
}

data class ClickableTextViewClickedDelegate(override val who: InteractiveViewState) : ClickableViewDelegate()

The delegatee of viewState, which is a feature, may map the delegate to an action and one of the feature's internal action worker will perform the action and complete the delegate. This leads to the next section talking about the action worker.

Action Worker

Action worker is declared to perform action and return ViewActionJob.

interface ViewActionWorker {
    /**
     * @throws ActionDeclinedException
     */
    @MainThread
    fun perform(action: ViewAction): ViewActionJob
}

What is more interesting is the ViewActionJob, see below its declaration. It's the first time in the code we see actual usage of coroutine suspend function. The optional coroutine enables imperateive paradigm for complicated business logics that are intrinsically asynchronous.

/**
 * indicates the caller of ViewAction whether the view action has been performed by the action worker
 * further, it offers a chance to the caller such that the caller can fetch and analyze the action result at a future time
 * @param performed whether the action has been performed by the action worker
 * @param asyncResult optional suspend function that yields ViewActionResult to the caller of ViewAction
 */
data class ViewActionJob(val performed: Boolean, val asyncResult: (suspend () -> ViewActionResult)?)

In order to demonstrate how business logic can be expressed as synchronous code based on the declaration of action worker and its optional return val asyncResult, let's deep dive into a pretty real scenario, implement a fragment that shows information regarding a job. What will be rendered on the fragment include job details, details of the job's hiring company, and similar jobs from the company. Based on what we have described so far, this simple product will be modeled as 4 features, a JobFeature that is parent of three child features. The three child features are jobDetailsFeature, companyDetailsFeature, and similarJobsFeature. Each child feature is fine-grained and self-contained, in the sense that, each child feature is able to invoke corresponding rest api call, wait and parse the rest call response, transform the response and update the viewStates it wraps. The invocation to rest api and the transformation of rest call response can be modeled as LoadAction and TransformAction. And the LoadActionResult will carry the rest call response, be it a success or failure.

/**
 * instructs a feature to load data that is necessary for the feature to render and function
 * what follows is typically a TransformAction that transforms the data loaded for the feature's view state to consume
 */
abstract class LoadAction(override val who: InteractiveFeature) : PerformerViewAction() {
    // when a caller calls an action worker with LoadAction
    // if execute is false, the ViewActionJob returned by the action worker shall have asyncResult for the caller to consume
    // if execute is true, the action worker will consume the ViewActionResult by itself, asyncResult will be null
    abstract val execute: Boolean
    abstract val toNextAction: (ViewActionResult) -> ViewAction
}
/**
 * instructs a feature to transform some data, typically for the feature's view state to consume
 */
abstract class TransformAction(override val who: InteractiveFeature) : PerformerViewAction()
abstract class LoadActionResult(override val who: InteractiveFeature) : ViewActionResult()

A pratical product requirement to the fragment may be, ideally show job details, company details, and similar jobs to the user; but, if similar jobs fail to load do not show it, if either job details or company details fail, put the fragment into error state (which e.g. offers a button for click-to-retry).

Here is how the parent JobFeature coordinates its three child features to accomplish the product requirements, in an imperative paradigm.

  • The JobFeature sends LoadActions to its child features respectively, wherein the "execute" boolean is false.
  • As the result, each child feature performs its LoadAction and returns ViewActionJob, wherein the "performed" boolean is true, and the optional "asyncResult" is non-null.
  • The JobFeature collects 3 ViewActionJobs, and executes 3 suspending functions, i.e. the asyncResults, in parallel. This is typically done by using async and await.
  • As the result, the JobFeatures collects 3 LoadActionResults for each of the child features. We know that the child feature is capable of transforming its own LoadActionResult.
  • Per the product requirement, the JobFeature starts to analyze the 3 LoadActionResults
    • If all LoadActionResults are success, the JobFeature creates 3 TransformActions that convey LoadActionResults for each child feature, and send the TransformActions to the 3 child features, respectively. The end result is all 3 child features rendered in the fragment.
    • If either LoadActionResult of jobDetailsFeature or companyDetailsFeature is failure, the JobFeature drops all 3 child features, update its own viewState which puts the fragment into error state.
    • If only the LoadActionResult of similarJobsFeature is failure, the JobFeature drops similarJobsFeature, and sends 2 TransformActions to jobDetailsFeature and companyDetailsFeature, respectively. This ends up with only jobDetailsFeature and companyDetailsFeature being rendered.

As can be seen, the suspending function captures the asynchronous nature of the product requirement. Specifically in this scenario, the rest api calls related asynchronous is expressed as suspend function. Business logic code is written step-by-step follows the product requirement, as if it is synchronously executed at runtime. The same synchronous impression also applies to reader of the business logic code. When all these further get empowered by the cancellable coroutine context, we can in addition achieve lifecycle-agnostic business logic code.

For the typical LiveData based approach, one may have to leverage observeForever for one portion of business logics and leverage observe inside the fragment for another portion of business logics. In comparison, the ADDS pattern allows expressing business logics in its entirety inside features, as either action or delegate. Since we know there will be a cancellable job protecting the business logics, we can code the action and delegate without explicitly consulting or observing LifecycleOwner, hence the ADDS pattern becomes lifecycle-agnostic in business logic implementation.

Lifecycle Job

Although not the key to the ADDS pattern, but for the sake of completeness of the cancellable job and how it helps in writing lifecycle-agnostic business logic code, below we show the code that extends every LifecycleOwner with a lifecycleJob.

val LifecycleOwner.lifecycleJob: Job by Delegates.lazyNotNull<LifecycleOwner, Job> { existingJob, owner ->
    // we need to create (or recreate) a lifecycle job iff
    // existingJob is null OR existingJob is cancelled but lifecycleOwner is re-initialized
    existingJob?.takeUnless {
        it.isCancelled && owner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)
    } ?: Job().apply {
        if (owner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED))
            owner.lifecycle.addObserver(object : LifecycleObserver {
                @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
                fun doOnDestroy() {
                    owner.lifecycle.removeObserver(this)
                    cancel()
                }
            })
        else cancel()
    }
}

On obtaining the lifecycleJob, it is trivial for a feature to guard its action workers' coroutine context, e.g. UI context, thanks to the plus operator. It is the Kotlin Coroutines library that enforces that a suspended action won't be resumed if the lifecycleJob is cancelled. Like the below rather generic actor backed action worker illustrated.

/**
 * create an action worker that is backed by an actor,
 * and it is the actor actually invokes a function closure to perform an action
 */
inline fun <reified T : PerformerViewAction, reified F : InteractiveFeature> createActorActionWorker(
        context: CoroutineContext = UI,
        start: CoroutineStart = CoroutineStart.UNDISPATCHED,
        capacity: Int,
        parentContext: Job,
        noinline block: suspend (T, F) -> Unit) = object : ViewActionWorker {
    private val internalWorker by lazy { createActorWorkerInternal(context, capacity, start, parentContext, block) }
    
    @MainThread
    override fun perform(action: ViewAction): ViewActionJob =
            if (action is T && action.who is F) internalWorker(action, action.who as F)
            else View_Action_Not_Performed
}

fun <T : PerformerViewAction, F : InteractiveFeature> createActorWorkerInternal(
        context: CoroutineContext,
        capacity: Int,
        start: CoroutineStart,
        parentContext: Job,
        block: suspend (T, F) -> Unit): ((T, F) -> ViewActionJob) {
    val actor = actor<T>(context, capacity, start, parentContext) {
        for (action in this) {
            block(action, action.who as F)
        }
    }

    fun _inner(action: T, feature: F): ViewActionJob = when (!actor.isClosedForSend && actor.offer(action)) {
        true -> View_Action_Performed
        else -> View_Action_Not_Performed
    }

    return ::_inner;
}

The actorActionWorker performs action inside its internal actor. The actor is created with a parentContext. When lifecycleJob is used as parentContext, cancelling the lifecycleJob effectively shuts down the actorActionWorker. No lifecycle releated concerns ever contaminate the business logic code, i.e. the suspend function (T, F) -> Unit and suspend function possibly conveyed by the action itself. Right next, we are going to show an ActivityTaskAction that carries business logic and also show an action worker that "safely", in terms of lifecycle and memory leak, captures an activity.

The ActivityTaskAction is declared as follows, what it carries in addition is an ActivityTask, a suspending function indeed. This ActivityTaskAction has pretty prevalent usage scenarios in a real app. For instance, user clicks a button that shall launch another activity. With the ADDS pattern, we know that the viewState, which the button binds to, will emit a delegate signals that it has being clicked. And the viewState's delegatee, a feature, will accept the delegate and it's the feature's responsibility to fulfill the product requirement, i.e. launch another activity. In order to launch activity, Activity.startActivity(intent: Intent) is our friend. The feature can have every business logic related to creation of the intent coded internal to itself, all that the feature needs is an action worker that holds an instance of activity to complete the activityTask.

data class ActivityTaskAction(override val who: InteractiveFeature, val task: ActivityTask) : PerformerViewAction()

typealias ActivityTask = suspend (FragmentActivity) -> Unit

And here is the ActivityTaskActionWorker that "safely" captures an activity so as to perform ActivityTaskAction. It is memory leak free because the activity captured is synchronously released when the activity is destroying, which it the same time that the lifecycleJob is cancelling. And of course, when the activity is destroying, the ActivityTaskActionWorker is shut down immediately.

/**
 * create an action worker that can perform ActivityTaskAction.
 * the action worker will release resource and refuse to perform future action once the activity gets destroyed
 */
inline fun <reified F : InteractiveFeature> FragmentActivity.createActivityTaskActionWorker(): ViewActionWorker =
        DecoratedVolatileActionWorker(
                createActorActionWorker<ActivityTaskAction, F>(parentContext = lifecycleJob) { action, _ ->
                    action.task(this)
                }
        ).apply {
            // synchronously disable the action worker upon cancellation of the lifecycleJob
            lifecycleJob.invokeOnCompletion { disable() }
        }

class DecoratedVolatileActionWorker(private var internalActionWorker: ViewActionWorker?) : ViewActionWorker {
    @MainThread
    override fun perform(action: ViewAction): ViewActionJob =
            internalActionWorker?.perform(action) ?: View_Action_Not_Performed

    /**
     * disables the DecoratedVolatileActionWorker and also frees resource that the internalActionWorker holds
     */
    @MainThread
    fun disable() {
        internalActionWorker = null
    }
}

Design Goals

We have not talked about design goals of the ADDS pattern, yet we do have design goals in mind the first day we started to explore this pattern. And here is the list of design goals along with analysis regarding how the ADDS pattern meet the goals.

  • Testability, and specifically Unit-Testability. For every feature developed, we'd like that the feature can be tested to the max extent by unit tests such that the necessity of Espresso UI tests is minimized. Compared to UI tests, unit tests are fast, reliable, and isolated. Unit test requires least possible amount of external dependency to mock and traverse least possible combinations of code branches.
    • With the ADDS pattern, the viewState is 100% unit-testable. For instance, a mock delegatee would suffice to assert a viewState emits the expected delegate upon a mock UI interaction by calling onClickListener that simulates a click.
    • Delegatee is 100% unit-testable. For instance, given mock dependencies to delegatee, we can assert how the delegatee behaves upon feeding it a mock delegate. Let's say the delegate is mapped to an action, then we can assert invocation to a mock action worker.
    • Action worker is 100% unit-testable. Similar to testing of delegatee, we can assert action worker's behavior via build the worker with mock dependencies and feed it with mock action. Let's say the action worker shall update viewState, we can assert what are passed to the mock viewState by the action worker, leveraging Captor.
    • Finally, because a feature is a composition of viewStates, action workers, and delegatee, it's then valid to infer that feature is 100% unit-testable. For instance, on top of fully unit-tested action worker, to assert a feature can correctly perform a particular action, it suffices to mock a particular action worker for the feature then assert the feature invokes the mock action worker upon feeding it the action.
  • Unidirectional data flow. This is necessary for unit-testability because unitdirectional data flow allows us to isolate code in a complex system and that each isolation has well defined input and output. Moreover, unidirectional data flow is also necessary to the followin design goals.
    • As we described so far, and also illustrated in the above figure, from UI interaction to UI update as the result of the interaction, the control flow is completely unidirectional. We'd like to reiterate that it is Kotlin Coroutines that makes the code completely imperative.
  • Readability & Maintainability. With time, product requirments change and code evolve. How hard or easy to read a chunk of existing code and refactor the code to accomodate change is a question an architecture pattern needs to answer.
    • We argue that the viewState can be quite generic, as the StdClickableTextVS shown above. We imagine that the viewStates can evolve into some sort of standard library that provides default implementations to most common two-way communications for many popular view widgets.
    • Because feature is fine-grained abstraction of business logics, and feature models business logics solely by action, and delegate, it shall be not hard to read code and make changes to feature starting from reading its action workers and deletagee. It's worth noting that one action worker does one thing and only one thing, and delegate is either mapped to action or sent to delegatee. This shall dramatically simplify reading, inferring, and changing action worker and delegatee to accomplish updated business logics.
  • Zero business logic in the Android classes, such as Fragment and Activity. This has its huge impact on unit-testability and readability and maintainability. From unit-testability point of view, business logics in the Android class inevitably necessitates Espresso UI tests. From readability and maintability point of view, business logics in the Android class results in unnecessary coupling, and oftentimes messy spreading of business logics in multiple places.
    • With the lifecycleJob indirection and the cancellable coroutine context library support from Kotlin Coroutines, it appears that the ADDS pattern may achieve zero business logics in the Android classes. The above ActivityTaskActionWorker is one example for how to take business logic code out of subclassing Activity.

Related Works

The community has had several popular architecture patterns, such as MVC, MVP, MVVM, MVI, Clean Architecture, Flux, RIBs, VIPER, etc. Unidirection data flow has been design goals of some patterns, such as Clean Architecture and Flux. Some patterns also promote reactive paradigm, such as Flux, MVI. The author also published a blog post promoting Functional Reactive Pattern.

With the recent introduction of AAC (Android Architecture Components) from Google, and in particular the LiveData of AAC, reactive architecture patterns receive even more interests from the community.

The ADDS pattern herein discussed are common to some of the existing patterns in terms of the design goals, such as unidirection data flow, viewState abstraction of user intent. However, the ADDS pattern probably differs from all the existing patterns in the conceptual components of action, delegate, chain of delegatee, and in that the ADDS pattern is completely imperative thanks to Kotlin Coroutines.

Specific to Android app development, Kotlin language makes it feasible to declare action and delegate at near zero cost, becuase of the lanauage offering of the extremely convenient Data Class. Also, since Kotlin functions are first class, it's trivial for delegate to carry functions that the emitter of the delegate won't be able to execute but a delegatee can. It's noteworthy that the action and delegate concepts can be implemented using Java language as well. We actually tried, but implementing action and delegate in Java is so verbose, painful, end up being preventive.

Moreover and most importantly, Kotlin Coroutines is crucial to the ADDS pattern. It is the powerful offering of Kotlin Coroutines, suspending functions, cancellable coroutine context, actor with backpressure, that enables the ADDS pattern being not only functional imperative but also lifecycle-agnostic.

Summary

In this blog post, we discussed the ADDS pattern, yet another Android architecture pattern. We think what might make the ADDS pattern interesting to the broader community is that the ADDS pattern is functional imperative, in contrast to the popular functional reactive. Reactive paradigm has gained strong intention in recent years in the community. With the new offering of Kotlin Coroutines, we have shown that the Android app development can enjoy imperative paradigm, despite the facts that the UI interaction and the product requirements are intrinsically asynchronous. The ADDS pattern is, arguably, 100% unit-testable, readable, and maintainable.

Disclaimer

The opinions expressed in this blog post are the author's own and do not necessarily reflect the views and opinions of the company the author is affiliated with.

Appendix

There are code omitted from the above discussions, such as the declaration of feature, since they are not technical essence of the ADDS pattern. Regardless, here are these code we guess some readers may want to have a look.

The (simplified) declaration of feature.

interface InteractiveFeature : InteractiveViewState, ViewActionWorker, ViewInteractionDelegatee {
    var parentContext: kotlinx.coroutines.experimental.Job
}

The creator of LoadActionWorker.

inline fun <reified T : LoadAction, reified F : InteractiveFeature> createLoadActionWorker(
        context: CoroutineContext = UI,
        start: CoroutineStart = CoroutineStart.UNDISPATCHED,
        capacity: Int = 0,
        parentContext: Job,
        crossinline block: suspend (F) -> ViewActionResult): ViewActionWorker = createActionWorker(
        createLoadActionWorkerInternal<T, F>(
                { _, feature -> ViewActionJob(true, { block(feature) }) },
                createActorWorkerInternal(context, capacity, start, parentContext) { action, feature ->
                    val result = block(feature)
                    feature.perform(action.toNextAction(result))
                }
        )
)

fun <T : LoadAction, F : InteractiveFeature> createLoadActionWorkerInternal(onDelayExecutionBlock: (T, F) -> ViewActionJob,
                                                                            onImmediateExecutionBlock: (T, F) -> ViewActionJob): ((T, F) -> ViewActionJob) {
    fun _inner(action: T, feature: F): ViewActionJob =
            if (action.execute) onImmediateExecutionBlock(action, feature) else onDelayExecutionBlock(action, feature)

    return ::_inner;
}

The skeleton of jobDetailsFeature and its loadActionWorker. Note that JobDetailsFeature is a child feature of JobFeature, to be shown next.

abstract class JobDetailsFeature : LoadableInteractiveFeature() {
    var loadActionWorker by Delegates.lazyNotNull<JobDetailsFeature, ViewActionWorker> { existing, _ ->
        existing?.takeIf { parentContext.isActive }
                ?: createLoadActionWorker<JobDetailsLoadAction, JobDetailsFeature>(parentContext = parentContext) { feature ->
                    JobDetailsLoadActionResult(feature,
                            feature.jobDetailsRepoFactory.provide(NetworkRepoFirst).single(JobDetailsRepoRequest))
                }
    }
}

interface LoadableFeature {
    fun createLoadAction(execute: Boolean = false): LoadAction
}

interface LoadableInteractiveFeature : InteractiveFeature, LoadableFeature

The skeleton of jobFeature and its loadActionWorker. Note that JobFeature is the parent feature of JobDetailsFeature shown above.

abstract class JobFeature : LoadableInteractiveFeature() {
    abstract override val subFeatures: MutableList<out LoadableInteractiveFeature>

    var loadActionWorker by Delegates.lazyNotNull<JobDetailsDemoFeature, ViewActionWorker> { existing, _ ->
        existing?.takeIf { parentContext.isActive }
                ?: createLoadActionWorker<JobLoadAction, JobFeature>(parentContext = parentContext) { feature ->
                    loadSubFeatures(feature, feature.subFeatures)
                }
    }
}

suspend fun <F : InteractiveFeature, R> loadSubFeatures(parentFeature: F, subFeatures: List<R>): SubFeaturesLoadActionResult
        where R : InteractiveFeature, R : LoadableFeature {
    val loadActions = subFeatures.map { it.createLoadAction() }
    val asyncResults = subFeatures.mapIndexed { index, feature ->
        async(coroutineContext, CoroutineStart.UNDISPATCHED) { feature.perform(loadActions[index]).asyncResult!!.invoke() }
    }
    val results = asyncResults.map { it.await() }
    val nextActions = loadActions.mapIndexed { index, loadAction ->
        loadAction.toNextAction(results[index])
    }
    return SubFeaturesLoadActionResult(parentFeature, subFeatures, results, nextActions)
}

And lastly, a (simplified) ReadableRepository and its factory.

interface ReadableRepository<in T : RepositoryRequest, R : RepositoryResponse> {
    suspend fun single(t: T): R
}

interface ReadableRepoFactory<in T : RepositoryRequest, R : RepositoryResponse> {
    fun provide(source: ReadableRepoSourceChoice): ReadableRepository<T, R>
}

Implement A Clickable Feature

Imagine a feature that has two clickable textViews. Below code shows how to glue delegate, delegatee, and viewState together and realize such a feature.

We start with defining the ClickableTextViewClickedDelegate.

/**
 * ViewDelegate with requester
 */
sealed class RequesterViewDelegate : ViewDelegate() {
    // "who" requested this delegation
    abstract val who: InteractiveViewState
}

abstract class ClickableViewDelegate : RequesterViewDelegate()

data class ClickableTextViewClickedDelegate(override val who: InteractiveViewState) : ClickableViewDelegate()

Then the standard viewState StdClickableTextVS.

/**
 * Standard abstract InteractiveViewState
 */
abstract class StdAbstractVS : InteractiveViewState {
    override var viewInteractionDelegatee: ViewInteractionDelegatee? = null
    protected open val obVisibility: ObservableBoolean? = null
    protected open val obClickability: ObservableBoolean? = null
    protected open val obText: ObservableField<CharSequence>? = null
}

open class StdTextOnlyVS : StdAbstractVS() {
    public override val obVisibility: ObservableBoolean = ObservableBoolean();
    public override val obText: ObservableField<CharSequence> = ObservableField()
}

/**
 * Standard Clickable TextView, it in addition has an observable OnClickListener
 * @throws UndeliveredException if delegatee does not accept ClickableTextViewClickedDelegate
 */
open class StdClickableTextVS : StdTextOnlyVS() {
    public override val obClickability: ObservableBoolean = ObservableBoolean()

    // for data binding
    val obListener: ObservableField<View.OnClickListener> = ObservableField(View.OnClickListener { view ->
        deliver(ClickableTextViewClickedDelegate(this))
    })

    override fun deliver(delegate: ViewDelegate): Boolean {
        val delivered = super.deliver(delegate)
        return when (delegate) {
            is ClickableTextViewClickedDelegate -> delivered || throw UndeliveredException(delegate)
            else -> delivered
        }
    }
}

In order to eliminate bolierplate code, StdClickableTextVSDelegatee, standard delegatee that matches stardard viewState, is defined as follows.

/**
 * Delegatee of a view state that hides complexity and details of the ViewState from the outer world
 * Specifically, the ViewStateDelegatee
 *  accepts ViewDelegate that the ViewState may emit
 *  provides convenience method to get/set view states
 */
interface ViewStateDelegatee<VS : InteractiveViewState> : ViewInteractionDelegatee {
    val vs: VS

    override fun accept(delegate: ViewDelegate): Boolean = false
}

interface StdTextOnlyVSDelegatee : ViewStateDelegatee<StdTextOnlyVS> {
    override val vs: StdTextOnlyVS

    fun setVisibility(visible: Boolean) = vs.obVisibility.set(visible)
    fun setText(text: CharSequence) = vs.obText.set(text)
    fun setTextAndMakeVisible(text: CharSequence) {
        setText(text)
        setVisibility(true)
    }
}

/**
 * Standard delegatee for ClickableTextVS, it accepts ClickableTextViewClickedDelegate and invokes the onClickedBusinessLogic
 */
interface StdClickableTextVSDelegatee : StdTextOnlyVSDelegatee {
    override val vs: StdClickableTextVS

    val onClickedBusinessLogic: (StdClickableTextVS) -> Unit

    fun setClickable(clickable: Boolean) = vs.obClickability.set(clickable)

    override fun accept(delegate: ViewDelegate): Boolean {
        return when (delegate) {
            is ClickableTextViewClickedDelegate -> {
                if (delegate.who !== vs) false
                else {
                    onClickedBusinessLogic(vs)
                    true
                }
            }
            else -> super.accept(delegate)
        }
    }
}

It's now time to implement The Feature that has two clickable textViews, let's say, one click-to-like a job posting, another click-to-dismiss a job posting.

Note again that the viewState, delegatee leveraged by this feature are standard, meaning that the viewState and delegatee are written once in the code base and are reusable to every feature.

In this particular sample feature case, the StdClickableTextVS and StdClickableTextVSDelegatee are readily reusable when we start to implement the feature. So all it needs in order to realize the feature is almost no more than implement the action worker, which is the business logic unique to this feature.

open class TheFeature : LoadableInteractiveFeature() {
    var dismissJob: StdClickableTextVS = object : StdClickableTextVS()
    var likeJob: StdClickableTextVS = object : StdClickableTextVS()

    // let the parent feature to determine how to dismiss this job
    lateinit var onDismissJobClickedBusiness: (StdClickableTextVS) -> Unit

    val onLikeJobClickedBusiness: (StdClickableTextVS) -> Unit = {
        this.perform(LikeJobAction(this))
    }

    var likeJobVsDelegatee = object : StdClickableTextVSDelegatee {
        override val vs: StdClickableTextVS by lazy { likeJob }
        override val onClickedBusinessLogic: (StdClickableTextVS) -> Unit by lazy { onLikeTheJobClickedBusiness }
    }

    var dismissJobVsDelegatee = object : StdClickableTextVSDelegatee {
        override val vs: StdClickableTextVS by lazy { dismissJob }
        override val onClickedBusinessLogic: (StdClickableTextVS) -> Unit by lazy { onDismissClickedBusiness }
    }

    override val interactionDelegatees: List<ViewInteractionDelegatee> by lazy {
        listOf(likeJobVsDelegatee, dismissJobVsDelegatee)
    }

    override val actionWorkers: List<ViewActionWorker> by lazy {
        listOf(likeActionWorker)
    }
    
    // likeActionWorker takes care of LikeJobAction, and typically uses Kotlin Coroutines to express the business logic 
    // var likeActionWorker: ViewActionWorker = createActorActionWorker(...)

    @MainThread
    override fun perform(action: ViewAction): ViewActionJob {
        actionWorkers?.forEach { consumer ->
            consumer.perform((action)).also { if (it.performed) return it }
        }
        return super.perform(action)
    }

    @MainThread
    override fun accept(delegate: ViewDelegate): Boolean {
        interactionDelegatees?.forEach { if (it.accept(delegate)) return true }
        return super.accept(delegate)
    }
}
@yshrsmz
Copy link

yshrsmz commented Jul 13, 2018

Hi, do you have any actual code written with this pattern?
This looks interesting, I'd like to see a working example!

@michaelzengke
Copy link
Author

@yshrsmz All the code above are excerpted from a running prototype. Sorry can't share the complete code of the prototype. Please feel free leave comments should you have questions on any piece of this pattern or how to glue the pieces together, etc. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment