Skip to content

Latest commit

 

History

History

helium-core

Helium Core

This Kotlin Multiplatform module is all you need to start building your own App Blocks on Android and iOS.

Helium requires java 8 support in your app's build.gradle.

Understanding the pattern

LogicBlock

  • can push state to a UiBlock via pushState(state)
  • receives BlockEvent from any attached UiBlock via onUiEvent(event)
  • receives lifecycle events (implements LifecycleObserver)
  • can be persisted across orientation changes (extends ViewModel)
  • no view references here, only state pushing and reacting to view events

UiBlock

  • Can render Android views according to the BlockState passed in render(state)
  • Can push BlockEvent to any attached LogicBlock via pushEvent(event)
  • This is the only place where you hold context or views
  • no business logic here, only enough to render the UI

Differences from other architecture patterns

  • Unlike MVP, a LogicBlock does not hold a reference to a UIBlock
  • Unlike MVVM, a UIBlock does not hold a reference to a LogicBlock
  • LogicBlock and UIBlock are bound by a simple final class called AppBlock

Notes on the implementation

A typical, real world example

Entry points

Your Activity or Fragment is always the entry point for your App blocks. There shouldn't be any logic in the entry points themselves, just enough to assemble the blocks together. Helium provides handy extension functions to make this easy and intuitive.

In an Activity:

class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val logic = MyLogic() // create a logic block
        val ui = MyUi(layoutInflater) // create a UI block        
        assemble(logic + ui) // assemble them
        setContentView(ui.view)
    }
}

In a Fragment:

class MyFragment : Fragment() {
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
      val logic = MyLogic() // create a logic block
      val ui = MyUi(inflater)  // create a UI block
      assemble(logic + ui)  // assemble them
      return ui.view
  }
}

That is all the wiring code you need. From there, you can write your logic and UI independently, with clear responsibilities for each and nice separation of concerns.

Retained logic

If you want your logic and latest state to be retained across configurations changes, simply replace MyLogic() with getRetainedLogicBlock<MyLogic>(). This will ensure your latest state is automatically restored after a configuration change.

You can also call your own constructor if you have dynamic data to pass to your logic, like an id from a bundle for example:

val id = intent.extras.getLong(DATA_ID)
val logic = getRetainedLogicBlock<MyLogic> { MyLogic(id) }

Implementing a Logic Block

The most common logic for mobile apps is to load some data from the network or a database, usually through a repository class.

Here's a example of LogicBlock that fetches some data, pushing the relevant states along the way, and reacts to user events coming from the UI.

class MyLogic(private val repository: MyRepository) : LogicBlock<MyState, MyEvent>() {

    @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
    private fun loadData() {
        launchInBlock { // launches a coroutine scoped to this LogicBlock
            try {
                pushState(MyState.Loading)
                val data = withContext(Dispatchers.IO) {
                    repository.getData()
                }
                pushState(MyState.DataReady(data))
            } catch(error: Exception) {
                pushState(MyState.Error(error))
            }
        }
    }

    override fun onUiEvent(event : MyEvent) {
        when (event) {
            is Click -> handleClick()
            is LongPress -> handleLongPress()
        }
    }
}

Note that loadData() is annotated with a @OnLifecycleEvent annotation, which can be used to schedule method calls when a certain lifecycle event happens. This is not required but is very useful in the Android world.

Note also that there is no UI references in this class, Logic Blocks should only care about pushing state, and handling events.

Implementing a UI Block

Now that your logic is defined with clear states, it's trivial to write a compatible UiBlock that renders the UI for each possible state, and pushes events when certain views get clicked.

class MyUi(inflater: LayoutInflater)
    : UiBlock<MyState, MyEvent>(inflater, R.layout.my_layout) {

    val myButton: TextView = findView(R.id.my_button)

    init {
        myButton.setOnClickListener { view -> pushEvent(MyEvent.Click(view)) }
    }

    override fun render(state: MyState) {
        when(state) {
            is Loading -> showLoading()
            is Error -> showError(state.error)
            is DataReady -> showData(state.data)
        }
    }
}

UI Blocks can inflate layouts for you, or you can pass a pre-inflated view hierarchy.

Note that there is no business logic in this class, UI Blocks should only care about rendering state, and pushing events.

State and events

In this example, we're using MyState and MyEvent as the medium of communication between our Logic and our UI. These state and event classes can be anything you want. One option is to use sealed Kotlin classes to define them:

sealed class MyState : BlockState {
    object Loading : MyState()
    data class Error(val error: Throwable) : MyState()
    data class DataReady(val data: MyData) : MyState()
}

sealed class MyEvent : BlockEvent {
    data class Click(val view: View) : MyEvent()
    data class LongPress(val view: View)  : MyEvent()
}

Helium Core provides the most common state and event types that you can use in your own blocks:

  • DataLoadState: a generic sealed class with all possible loading states when fetching data. Great to use for any logic block whose job is to fetch some data asynchronously.
  • ClickEvent: a simple, generic data class to describe a user click event, passing a data model and the view that was clicked.

More Code Samples