Macao is a server-driven-ui SDK, it allows you to change the look and feeling, and the behavior of an Application from a remote json configuration file. Bypassing the Google and Apple stores offering a seamless update experince to the users.
One of the goals of the Macao SDK is easy of extendibility, its architecture allows collaborators to easily incorporate new features into the SDK, as plug-ins that get injected in the SDK using koin modules.
- Koin
- Compose Navigation
- Compose Lifecycle-ViewModel
- Ktor(coming soon)
- Room(coming soon)
- Coil(coming soon)
Android, iOS, JVM and JS, Wasm not supported yet
-
A data class that defines a destination in our domain. It contains the metadata associated to a compose destination. The DestinationInfo model is usually loaded remotely as users navigate the nav graph. This metadata can change from time to time so it is important to set a considerable cache time depending how often you plan to update your App.
data class DestinationInfo( // Remote Data val route: String, val renderType: String, val dataSource: String, // Presentation val label: String, val icon: ImageVector, val badgeText: String? = null, // To pass input params from parents down to children. // Basically is the "Props" pattern taken from ReactJS. var props: Bundle? = null )
-
A
DestinationRender
is intended to be a function that knows how to render a specific destination type. We decided to use an interface for easy binding withkoin
but implementaions of this interface must not contain state at all.interface DestinationRender { fun getRenderType(): String @Composable fun Content( destinationInfo: DestinationInfo, navController: NavHostController, navBackStackEntry: NavBackStackEntry, resultHandler: ResultHandler<DestinationResult<*>> ) }
Examples implementations are
DrawerDestinationRender
orBottomNavigationRender
orMyCustomScreenDestinationRender
. -
Similar to a
DestinationRender
but its composable render function does not receive neither a navController nor a navBackStackEntry because it is a root Composable and it doesn't need those.interface RootDestinationRender { fun getRenderType(): String @Composable fun Content( destinationInfo: DestinationInfo, viewModelStoreOwner: ViewModelStoreOwner ) }
-
DestinationRendersRegistry
is basically a mapper ofDestinationInfo
and itsDestinationRender
types. It would resolve the rightDestinationRender
orRootDestinationRender
implementation for a given DestinationInforenderType: String
. EachDestinationInfo
must define what type of renderType will handle its rendering. This type has to match at least oneDestinationRender
registered in the registry. The DestinationRender implementations have to be defined at build time and registered in the DestinationRendersRegistry before a DestinationRender asks for it. Reflection in Kotlin Native doesn't allow instantiating a class given its String name, so we have to register each DestinationRender at build time. Koin certainly helps to achieve this using the module scanning feature -
As its name indicates the purpose of classes implementing this interface, is basically remotely load the intial navigation graph destination metadata.
interface RootGraphInitializer { fun shouldShowLoader(): Boolean suspend fun initialize( koinComponent: KoinComponent ): MacaoResult<DestinationInfo, RootGraphInitializerError> }
The
RootGraphInitializer
will remotely load the necessary metadata to determine who will be theRootDestinationRender
of the App. In theinitialize(...)
function it gets provided with the rootkoinComponent: KoinComponent
. So it can get whatever dependency it needs from it. Eg: HttpService, Repository to fetch the initial Destination metadata. -
This interface is defined as:
interface RootKoinModuleInitializer { suspend fun initialize() : List<Module> }
The
RootKoinModuleInitializer
implementation will be in charge of setting up the minimum necessary Koin dependencies for the App to complete the Startup process. These dependencies usually include a network library to access theapp-config.json
file remotely defined, and a local database to save and cache the Destinations metadata retrieved remotely. SeeCommonKoinModulesInitializer
class and the specific platform subclasses likeAndroidKoinModulesInitializer
to see how to add dependencies that belong to commonMain or to each specific platform. -
A StartupTask is a task that runs anytime the Application is launched, either tapping on the App icon or tapping on a notification.
These tasks perform the fundamental operations for the App to properly function. Example of this task are:- Database migration
- Third party SDKs initialization
- Feature flags toggles, for example LaunchDarkly
- Other App configurations in your server
interface StartupTask {
fun name(): String
/**
* This function dictates whether the initialization will actually take place
* or it will rely on cached computations from previous launches.
* */
fun shouldShowLoader(): Boolean
/**
* This function should be executed in io/default dispatcher.
* Things like Database Migration and LaunchDarkly initialization
* are examples of StartupTasks.
* */
suspend fun initialize(koinComponent: KoinComponent): MacaoResult<Unit, StartupTaskError>
}
The task execution doesn't necessarely has to do heavy work all the time. Some times the task knows it has some data cached and the next executione will take a few milliseconds. In this case, the StartupTask can return false
in the function fun shouldShowLoader(): Boolean
and the loader in the SplashScreen
wont be shown.
If no task do heavy work(all them return false), then we omit the SplashScreen animation
Adding a new screen(destination) to the library is a matter of completing a few steps. Lets do a Drawer as an example.
1- The first thing is create our custom DemoDrawerViewModel
class which extends from DrawerViewModel
which extends from DestinationViewModel
which extends from our beloved Jetpack ViewModel
.
Your ViewModel will look like this:
class DemoDrawerViewModel(
private val drawerDataSource: DemoDrawerDataSource,
override val drawerStatePresenter: DrawerStatePresenterDefault,
override val destinationRendersRegistry: DestinationRendersRegistry
) : DrawerViewModel() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
override fun onStart(destinationInfo: DestinationInfo) {
println("DemoDrawerViewModel[${instanceId()}]::onStart()")
coroutineScope.launch {
val childDestinations = stateLoaderUseCase
.loadChildrenDestinations(destinationInfo.dataSource)
val navItemDecoNewList = childDestinations.map {
it.toDrawerNavItem()
}
drawerStatePresenter.setNavItemsDeco(navItemDecoNewList)
}
}
override fun onStop() {
println("DemoDrawerViewModel[${instanceId()}]::onStop()")
}
override fun handleBackPressed() {
println("DemoDrawerViewModel[${instanceId()}]::handleBackPressed()")
}
override fun handleBackPressed() {
}
}
2- Once your custom DrawerViewModel
is defined, then we are going to do the custom DestinationRender
, in this case lets do a RootDestinationRender
for simplicity.
Notice bellow how the integration with Koin
allows us to inject the ViewModel instance and scope it to the nearest NavBackStackEntry.
class DemoDrawerRootDestinationRender : RootDestinationRender {
override fun getRenderType(): String = ServerUiConstants.ComponentType.Drawer
@Composable
override fun Content(rootDestinationInfo: DestinationInfo) {
val viewModel = koinViewModel<DemoDrawerViewModel>()
DrawerView(viewModel)
}
}
3- The third step is providing a Koin module that creates the instances of your classes
internal val drawerRootDestinationModule = module {
factoryOf(::DemoDrawerDataSource)
factory<DrawerStatePresenterDefault> {
DrawerComponentDefaults.createDrawerStatePresenter()
}
// DrawerViewModel
viewModelOf(::DemoDrawerViewModel)
factory { DemoDrawerRootDestinationRender() } bind (RootDestinationRender::class)
}
The bind (RootDestinationRender::class)
is a must for Koin to include it in a list of all
DestinationRender implementations. We then get that list and register all the renders in the
DestinationRendersRegistry
.
4- And finally we need to add the newly created koin module in the CommonKoinModulesInitializer
abstract class CommonKoinModulesInitializer(
private val ioDispatcher: CoroutineDispatcher = getCoroutineDispatchers().io
) : KoinModulesInitializer {
override suspend fun initialize(): List<Module> = withContext(ioDispatcher) {
commonKoinModules() + platformKoinModules()
}
private suspend fun commonKoinModules(): List<Module> = mutableListOf<Module>().apply {
...
// Drawer module
add(drawerModule)
...
}
}
And that was it. Above steps will give you something like shown in the gif image bellow:
In Progress ...