Authors: Wang Peng, Sun Yongsheng

Source: Bytedance Technology team

What is MAD?

MAD, which stands for Modern Android Development, is a collection of technology stacks and toolchains covering everything from programming languages to Development frameworks.

The SDK has not changed much in the years since the launch of Android in 2008, and the development method is relatively fixed. The technology update has been accelerating since 2013, and especially since then, with the advent of new technologies like Kotlin and Jetpack, which last year brought the evolution of Android development to a new level. Google named its approach to development under these new technologies MAD to distinguish it from the old, inefficient approach.

MAD can guide developers to develop great mobile apps more efficiently. Its main advantages are as follows:

  • Reliable: Brings together Google’s more than ten years of cutting-edge development experience in Android industry
  • Friendly to start: provides a large number of demos and reference documents, suitable for different stages and different sizes of projects
  • Efficient startup: Build your project quickly with frameworks like Jeptack and Jetpack Compose
  • Free choice: A wide variety of frameworks can be used with traditional languages, native development, and open source frameworks
  • Consistent experience: The development experience of different devices and versions is consistent

MAD helps the application go to sea

We recently launched an AI effects app on GooglePlay, which can transform a user’s profile picture into a variety of artistic effects through algorithms. The app has been well received, thanks to the combination of MAD technologies we used in the project, which we completed in the shortest possible time and created a great user experience.

The code architecture of the project under MAD’s guidance was also more reasonable and maintainable. Below is the overall application of MAD in the project:

Next, this article will share some of our experiences and cases in the practice of MAD.

1. Kotlin

Kotlin is the preferred development language approved by Andorid, and all code in our project is developed using Kotlin. Kotlin’s syntax is concise, and the code size can be reduced by 25% compared to Java equivalent functionality. Kotlin also has many nice features that Java does not:

1.1 Safety

Kotlin has many good designs for security, such as air security and data immutability.

Null Safety

Kotlin’s empty safety features allow many runtime Npes to be exposed and discovered at compile time, effectively reducing the occurrence of online crashes. In our code, we attach importance to the judgment and processing of Nullable types. We strive to avoid Nullable types when defining data structures, so as to minimize the Nullable detection cost.

interface ISelectedStateController<DATA> { fun getStateOrNull(data: DATA): SelectedState? fun selectAndGetState(data: DATA): SelectedState fun cancelAndGetState(data: DATA): SelectedState fun clearSelectState()} // Use Elvis to handle Nullable fun <DATA> ISelectedStateController<DATA>.getSelectState(data: DATA): SelectedState { return getStateOrNull(data) ? : SelectedState.NON_SELECTED }Copy the code

In the Java era, we could only use a naming convention like getStateOrNull to remind us of the nullability of a return value. ** allows us to better perceive Nullable risks; We could also use the Elvis operator, right? : Converts Nullable to NonNull for subsequent use. Kotlin!!!! Makes it easier to spot potential risks of NPE and can resort to static checks to warn.

Kotlin’s default parameter values feature can also be used to prevent NPE from appearing, as structure definitions like the one below do not need to worry about Null in scenarios such as deserialization.

data class BannerResponse(
    @SerializedName("data") val data: BannerData = BannerData(),
    @SerializedName("message") val message: String = "",
    @SerializedName("status_code") val statusCode: Int = 0
)
Copy the code

After we fully embraced Kotlin, the crash rate on THE NPE side was only 0.3 ‰, compared with more than 1 ‰ for Java projects

Immutable

Kotlin’s security is also based on the fact that data cannot be arbitrarily modified. The heavy use of data classes in our code and the requirement that the attributes be defined with Val instead of VAR helped to promote the one-way data flow paradigm in the project, enabling read and write separation of data at the architectural level.

data class HomeUiState(
    val bannerList: Result<BannerItemModel> = Result.Success(emptyList()),
    val contentList: Result<ContentViewModel> = Result.Success(emptyList()),
)

sealed class Result<T> {
    data class Success<T>(val list: List<T> = emptyList()) : Result<T>()
    data class Error<T>(val message: String) : Result<T>()
}
Copy the code

As above, we use the Data class to define UiState for use in the ViewModel. The val declaration property guarantees the immutability of State. Using a sealed class to define Result facilitates enumeration of various request results, simplifying logic.

private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

_uiState.value =
    _uiState.value.copy(bannerList = Result.Success(it))
Copy the code

When State needs to be updated, the copy method of the Data class can be used to quickly copy and construct a new instance.

Immutable is also true for the types of collection classes. In our project, we advocate the non-essential don’t use MutableList Mutable types, can reduce the multithreaded problems such as ConcurrentModificationException, at the same time, more important is avoided because of the Item to tamper with data consistency problem:

viewModel.uiState.collect { when (it) { Result.Success -> bannerAdapter.updateList(it.list) else {... } } } fun updateList(newList: List<BannerItemModel>) { val diffResult = DiffUtil.calculateDiff(BannerDiffCallback(mList, newList), true) diffResult.dispatchUpdatesTo(this) }Copy the code

For example, in the above example, the UI side submits the DiffUtil refresh list after receiving the UiState update notification. DiffUtil works precisely because mList and newList remain Immutable at all times.

1.2 the Functional

Functions are first-class citizens in Kotlin and can form higher-order functions as arguments or return value types, which provide a more user-friendly API in scenarios such as collection operators.

Collection operations

val bannerImageList: List<BannerImageItem> = bannerModelList.sortedBy { it.bType }.filter { ! it.isFrozen() }.map { it.image }Copy the code

In the above code, we sorted the BannerModelList, filtered it, and converted it to a list of type BannerImageItem. The use of the collection operator made the code work in one go.

Scope functions

A scope function is a series of inline higher-order functions. They act as a glue for your code, reducing the need for unnecessary code such as temporary variables.

GalleryFragment().apply { setArguments(arguments ? : Bundle().apply { putInt("layoutId", layoutId()) }) }.let { fragment -> supportFragmentManager.beginTransaction() .apply { if (needAdd) add(R.id.fragment_container, fragment, tag) else replace(R.id.fragment_container, fragment, tag) }.also{ it.setCustomAnimations(R.anim.slide_in, R.anim.slide_out) }.commit() }Copy the code

When we create and start a Fragment, we can do various initializations based on scoped functions, as in the example above. This example is also a reminder that overuse of these scoped functions (or collection operators) can also affect the readability and debuggability of your code, and only “proper” use of functional programming can truly take advantage of Kotlin.

1.3 Corroutine

Kotlin coroutines have freed developers from callback hell, and while structured concurrency helps manage subtasks better, Android’s various native and tripartit libraries are turning to Kotlin coroutines for asynchronous tasks.

Suspend function

In the project, we advocated the use of suspend functions to encapsulate asynchronous logic. Needless to say, some presentation layer logic can also be implemented based on suspended functions:

suspend fun doShare(
    activity: Activity,
    contentBuilder: ShareContent.Builder.() -> Unit
): ShareResult = suspendCancellableCoroutine { cont ->
    val shareModel = ShareContent.Builder()
        .setEventCallBack(object : ShareEventCallback.EmptyShareEventCallBack() {
            override fun onShareResultEvent(result: ShareResult) {
                super.onShareResultEvent(result)
                if (result.errorCode == 0) {
                    cont.resume(result)
                } else {
                    cont.cancel()
                }
            }
        }).apply(contentBuilder)
        .build()
    ShareSdk.showPanel(createPanelContent(activity, shareModel))
}
Copy the code

The doShare example above handles the sharing logic of photos with a suspend function: a sharing panel pops up for the user to select a sharing channel and returns the sharing result to the caller. The code style is more intuitive as the caller initiates the share and synchronously gets the result of the success or failure of the share.

Flow

Using Flow instead of RxJava to process streaming data, CoroutineScope can effectively avoid data leakage while reducing package size:

fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> =
    DatabaseManager.db.bannerDao::getAll.asFlow()
            .onCompletion {
                this@Repository::getRemoteBannerList.asFlow().onEach {
                    launch {
                        DatabaseManager.db.bannerDao.deleteAll()
                        DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
                    }
                }
            }.distinctUntilChanged()
Copy the code

The above example is used to get a BannerList from multiple data sources. We added a policy of disk caching, requesting local database data first and then remote data. The use of Flow is ideal for such scenarios involving multiple data source requests. On the other side of the call, as long as the appropriate CoroutineScope is provided, there is no need to worry about leaks.

1.4 KTX

Some Android libraries that were originally implemented in Java provide extended apis for Kotlin via KTX, making them easier to use in the Kotlin project.

Our project uses Jetpack Architecture Components to build the App infrastructure, and KTX helped us significantly reduce the cost of using apis in the Kotlin project. Here are a few examples of the most common KTX:

fragment-ktx

Fragment-ktx provides several Kotlin extensions for fragments, such as the creation of a ViewModel class HomeFragment: Fragment() { private val homeViewModel : HomeViewModel by viewModels() ... }Copy the code

Creating a ViewMoel in a Fragment is extremely simple compared to Java code, but behind it is the clever use of various Kotlin features.

inline fun <reified VM : ViewModel> Fragment.viewModels(
    noinline ownerProducer: () -> ViewModelStoreOwner = { this },
    noinline factoryProducer: (() -> Factory)? = null
) = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)
Copy the code

ViewModels is an inline extension of the Fragment method that gets a generic type at run time via the reified keyword to create a concrete ViewModel instance:

fun <VM : ViewModel> Fragment.createViewModelLazy( viewModelClass: KClass<VM>, storeProducer: () -> ViewModelStore, factoryProducer: (() -> Factory)? = null ): Lazy<VM> { val factoryPromise = factoryProducer ? : { defaultViewModelProviderFactory } return ViewModelLazy(viewModelClass, storeProducer, factoryPromise) }Copy the code

CreateViewModelLazy returns a Lazy instance, just as we can create a ViewModel with the by keyword, which uses Kotlin’s proxy feature to implement Lazy instance creation.

viewmodle-ktx

Viewmodel-ktx provides extension methods for viewModels, such as viewModelScope, to terminate expired asynchronous tasks as the viewModel is destroyed, making it safer to use the viewModel as a bridge between the data layer and the presentation layer.

Viewmodelscope.launch {// Listen for data rebo.getMessage (). Collect {// Send messages to the presentation layer _messageflow.emit (message)}}Copy the code

The implementation principle is very simple:

val ViewModel.viewModelScope: CoroutineScope get() { val scope: CoroutineScope? = this.getTag(JOB_KEY) if (scope ! = null) { return scope } return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) }Copy the code

ViewModelScope is essentially an extension of the ViewModle property. Create CloseableCoroutineScope via custom Get and record the position of the JOB_KEY.

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}
Copy the code

The CloseableCoroutineScope is a Closeable that looks for the JOB_KEY on the ViewModel’s onClear and is called close to cancel the container job and terminate all the subroutines. KTX takes advantage of various Kotlin features and syntactic sugar, more of which will be seen in the Jetpack section below.

2. Android Jetpack

Android provides developers with basic capabilities on top of AOSP through Jetpack, ranging from UI to Data, reducing the need for developers to build their own wheels. Recently, the Jetpack component architecture specification has been fully updated to help us better implement the design goal of separation of concerns during development.

2.1 Architecture

Android uses Unidirectional Data Flow to communicate Data by separating the presentation and Data layers. Jetpack supports UDF landing on Android through a series of Lifecycle- Aware components.

The main features and advantages of UDF are as follows:

  • Unique Real Source (SSOT) : UI State is centrally managed in the ViewModel, reducing the cost of synchronization between multiple sources
  • Data flows from top to bottom: UPDATES to the UI change the state of the VM, and the UI itself holds no state, decoupled business logic
  • Bottom-up event delivery: THE UI sends events to the VM to modify the state in a centralized manner, and the state changes are traceable and conducive to single test

All business scenarios involving the UI in the project are built based on UDF. Take HomePage for example, including BannerList and ContentList two sets of data display, all data centralized management in UiState.

class HomeViewModel() : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() fun fetchHomeData() { fetchJob? .cancel() fetchJob = viewModelScope.launch { with(repo) { //request BannerList try { getBannerList().collect { _uiState.value = _uiState.value.copy(bannerList = Result.Success(it)) } } catch (ioe: IOException) { // Handle the error and notify the UI when appropriate. _uiState.value = _uiState.value.copy( bannerList = Result.Error(getMessagesFromThrowable(ioe)) ) } //request ContentList try { getContentList().collect { _uiState.value = _uiState.value.copy(contentList = Result.Success(it)) } } catch (ioe: IOException) { _uiState.value = _uiState.value.copy( contentList = Result.Error(getMessagesFromThrowable(ioe)) ) } } } } }Copy the code

As shown in the code above, HomeViewModel retrieves data from the Repo and updates the UiState, and the View subscribes to this state and refreshes the UI. The CoroutineScope provided by ViewModelScope.launch can end a running coroutine with ViewModel’s onClear, avoiding leaks.

Data layer We use the Repository Pattern to encapsulate the implementation of local and remote data sources:

class Repository {
    fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> {

        return DatabaseManager.db.bannerDao::getAll.asFlow()
            .onCompletion {
                this@Repository::getRemoteBannerList.asFlow().onEach {
                    launch {
                        DatabaseManager.db.bannerDao.deleteAll()
                        DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
                    }
                }
            }.distinctUntilChanged()
    }

    private suspend fun getRemoteBannerList(): List<BannerItemModel> {
        TODO("Not yet implemented")
    }
}
Copy the code

Take getBannerList as an example. It first requests local data from the database for accelerated display, and then requests remote data sources to update the data. At the same time, it persists the data for the next request.

The logic at the UI layer is simple: subscribe to the ViewModel’s data and refresh the UI.

@AndroidEntryPoint
class HomeFragment : Fragment()  {

    @Inject
    lateinit var viewModel : HomeViewModel

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}
Copy the code

We use Flow instead of LiveData to encapsulate UiState. LifecycleScope makes Flow transform into Lifecycle-aware component. RepeatOnLifecycle enables Flow to automatically stop transmitting data Flow when switching between front and back parts of Fragment just like LiveData, saving resource overhead.

2.2 Navigation

As practitioners of the “single Activity architecture”, we chose to use Jetpack Navigation as the Navigation component of our App. The Navigation component implements Navigation design principles, provides a consistent user experience for switching across applications or between pages within applications, and provides various benefits including:

  • Fragment transaction;
  • By default, round-trip operations are handled correctly;
  • Provide standardized resources for animations and transitions;
  • Implement and handle deep linking;
  • Navigation interface modes (e.g. drawer navigation and bottom navigation) require minimal extra work;
  • Gradle plugins are provided to ensure type safety when passing parameters between different pages.
  • Viewmodels of navigation graph scopes are provided to share data with pages in the navigation graph;

Navigation provides configuration in both XML and Kotlin DSL. We took advantage of Kotin’s strengths in the project, creating navigation diagrams based on type-safe DSLS, while uniformly specifying transition animations for the page through function extraction:

fun NavHostFragment.initGraph() = run { createGraph(nav_graph.id, nav_graph.dest.home) { fragment<HomeFragment>(nav_graph.dest.effect_detail) { action(nav_graph.action.home_to_effect_detail) { destinationId = nav_graph.dest.effect_detail navOptions { ApplySlideInOut ()}}}}} / / unified specified animated transitions internal fun NavOptionsBuilder. ApplySlideInOut () {anim {enter = state Richard armitage nim. Slide_in  exit = R.anim.slide_out popEnter = R.anim.slide_in_pop popExit = R.anim.slide_out_pop } }Copy the code

In the Activity, call initGraph() to initialize the navigation graph for the Root Fragment:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val navHostFragment: NavHostFragment by lazy {
        supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        navHostFragment.navController.apply {
            graph = navHostFragment.initGraph()
        }
    }
}
Copy the code

FindNavController (), provided with navigation-fragment-ktx, can always jump correctly to the page based on the current Destination:

@AndroidEntryPoint
class EffectDetailFragment : Fragment() {

    /* ... */

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        nextButton.setOnClickListener {
            findNavController().navigate(nav_graph.action.effect_detail_to_loading))
        }

        // Back to previous page
        backButton.setOnClickListener {
            findNavController().popBackStack()
        }

        // Back to home page
        homeButton.setOnClickListener {
            findNavController().popBackStack(nav_graph.dest.home, false)
        }
    }
}
Copy the code

In addition, we can declare global page navigation, which is useful in situations such as directing users to login for registration or to feedback pages:

fun NavHostFragment.initGraph() = run {
    createGraph(nav_graph.id, nav_graph.dest.home) {
        /* ... some Fragment destination declaration ... */
        // --------------- Global ---------------
        action(nav_graph.action.global_to_register) {
            destinationId = nav_graph.dest.register
            navOptions {
                applyBottomSheetInOut()
            }
        }
    }
}
Copy the code

2.3 Hilt

Dependency Injection is a commonly used technology in multi-module projects. As an implementation of the design principle of inversion of control, Dependency Injection is conducive to decoupling the production side and the consumption side of instances, practicing the design principle of separation of concerns, and is more conducive to the writing of unit tests.

Hilt is built on Dagger and inherits the advantages of Dagger compile-time checking, runtime high performance and scalability while providing a friendlier API, which significantly reduces the cost of using Dagger. Android Studio also has built-in support for Dagger/Hilt, which I’ll cover later.

Hilt was extensively used in the project to complete dependency injection, which further improved the efficiency of code writing. We use @singleton to provide a Singleton implementation of Repository. When Repository needs Context to create SharedPreferences or DataStore, Use the @ApplicationContext annotation to pass in the application-level Context, Inject objects where needed only ** @inject ** :

@AndroidEntryPoint
class RecommendFragment : Fragment() {
    @Inject
    lateinit var recommendRepository: RecommendRepository

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        recommendRepository.doSomeThing()
    }
}
Copy the code

For classes with a three-party library that cannot add annotations to the constructor, we can use @provides to tell Hilt how to create related instances. For example, provide an implementation to create the Retorfit API, eliminating the need to manually create it each time.

@Module
@InstallIn(ActivityComponent::class)
object ApiModule {

    @Provides
    fun provideRecommendServiceApi(): RecommendServiceApi {
        return Retrofit.Builder()
                .baseUrl("https://example.com")
                .build()
                .create(RecommendServiceApi::class.java)
    }
}
Copy the code

Hilt can also be used for dependency injection in ViewModel or WorkManager, thanks to Hilt’s support for other components of Jetpack.

@HiltViewModel
class RecommendViewModel @Inject constructor(
    private val recommendRepository: RecommendRepository
) {

    val recommendList = recommendRepository.fetchRecommendList()
        .flatMapLatest {
            flow { emit(it) }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
}
Copy the code

2.4 the WorkManager

WorkManager is a Jetpack library for persistent work, tasks that can be performed continuously across applications or system restarts, such as synchronizing application data with servers or uploading logs. The WorkManager automatically selects FirebaseJobDispatcher, GcmNetworkManager, and JobScheduler to perform scheduling tasks based on internal policies, and provides a simple and consistent API for easy use externally.

The WorkManager is initialized by default using the Jetpack StartUp library. Developers only need to focus on defining and implementing the Worker and no additional work is required. WorkManager is backward-compatible with Android 6.0 and covers the vast majority of devices on the market, effectively replacing Services for long-running background tasks.

In order to reduce the time and traffic consumption required for uploading pictures when users generate profile pictures, the product will compress pictures before uploading. However, temporary files in the compression process will increase the storage space occupied by App. Therefore, we use WorkManager to schedule the cleaning of compressed picture cache. Submit tasks to WorkManager after App startup:

val deleteImageCacheRequest = OneTimeWorkRequestBuilder<DeleteImageCacheWorker>().build()
WorkManager.getInstance(this).enqueue(deleteImageCacheRequest)

class DeleteImageCacheWorker(
    context: Context,
    workParams: WorkerParameters
) : Worker(context, workParams) {

    override fun doWork(): Result {
        return try {
            /* ... do the work ... */
            Result.success()
        } catch (e: Exception) {
            /* return failure() or retry() */
            Result.failure()
        }
    }
}
Copy the code

Another scenario is that users download images. Download requires network, and this work is of high priority. Therefore, you can use the work constraint and urgent work (WorkManager 2.7 and above) provided by WorkManager. In addition, you can also monitor the work result information to prompt users:

val downloadImageRequest = OneTimeWorkRequestBuilder<DownLoadImageWorker>()
    .setInputData(workDataOf("url" to "https://the-url-of-image.com"))
    // set network constraint
    .setConstraints(
        Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
    )
    // make worker expedited
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()
WorkManager.getInstance(context).enqueue(downloadImageRequest)

val downloadImageFlow = WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(downloadImageRequest.id)
    .asFlow()
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        replay = 1
    )

// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        downloadImageFlow.collectLatest {
            when (it?.state) {
                WorkInfo.State.ENQUEUED -> {}
                WorkInfo.State.RUNNING -> {}
                WorkInfo.State.SUCCEEDED -> {}
                WorkInfo.State.BLOCKED -> {}
                WorkInfo.State.FAILED -> {}
                WorkInfo.State.CANCELLED -> {}
            }
        }
    }
}
Copy the code

2.5 StartUp

A lot of initialization work needs to be done during application startup, such as SDK initialization and basic module configuration. Before StartUp, we use ContentProvider to complete the “invection-free” initialization, avoiding the occurrence of init(Context) code in the Application. However, the creation cost of ContentProviders is high. Creating multiple ContentProviders at the same time will slow down the application startup speed and the initialization sequence is uncontrollable.

StartUp uses only one ContentProvider to initialize multiple components. In addition, StartUp avoids unnecessary dependency of app modules on other modules. For example, in our project we need to rely on a separate Module for the Local test channel. This Module relies on the Context for initialization, but we don’t want it to be pushed into the Release package. The app does not need to rely on the local_test module at the code level.

if (BuildContext.isLocalTest()) {
    implementation project(':local_test')
}
Copy the code

Using the StartUp library is very simple. You only need to define an Initializer. You can also configure the initialization dependencies to ensure that the core components can be initialized first:

class ServerInitializer : Initializer<ServerManager> {
    override fun create(context: Context): ServerManager {
        TODO("init ServerManager and return")
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList()
    }
}

class AccountInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        TODO("init Account")
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return listOf(ServerInitializer::class.java)
    }
}
Copy the code

In the example above, the initialization of the Account module will not continue until the Server module is initialized.

2.6 Room

Local-first apps can provide good user experience. When the device cannot access the network, users can still browse the relevant content offline. Android provides SQLite as an API to access the database, but SQLite API is relatively low-level, requiring manual assurance of the correctness of SQL statements, in addition to writing a lot of template code to complete the conversion between PO and DO. Jetpack Room provides an abstraction layer on top of SQLite to help developers access databases more smoothly.

Room mainly consists of three components: Database is the Database holder and the main access point to the underlying Database; Entity represents a table in the database; The DAO contains methods for accessing the database. Three components are declared with annotations:

@Entity(tableName = "tb_banner")
data class Banner(
    @PrimaryKey
    val id: Long,
    @ColumnInfo(name = "url")
    val url: String
)

@Dao
interface BannerDao {
    @Query("SELECT * FROM tb_banner")
    fun getAll(): List<Banner>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertBanner(banner: Banner)
}

@Database(entities = arrayOf(Banner::class), version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun bannerDao(): BannerDao
}
Copy the code

It should be noted that the cost of creating a database is relatively high, so the database in the single-process App should be a singleton:

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext applicationContext: Context
    ): AppDatabase {
        return Room.databaseBuilder(
                    applicationContext,
                    AppDatabase::class.java, "database-name"
                ).build()
    }

    @Provides
    @Singleton
    fun provideBannerDao(
        appDatabase: AppDatabase
    ): BannerDao {
        return appDatabase.bannerDao()
    }

}
Copy the code

When the data in the database is updated, we want the UI to refresh automatically. Thanks to Room’s support for Coroutine and RxJava, methods in DAO can also return flows or Observables directly, or use suspended functions, just by using room-KTX or Room-RxJavA2/3 libraries:

@Dao
interface BannerDao {
    @Query("SELECT * FROM tb_banner")
    fun getAll(): Flow<List<Banner>>

    @Query("SELECT * FROM tb_banner")
    suspend fun getAllSuspend(): List<Banner>>
}
Copy the code

Simply subscribe to the Flow at the UI level, and the UI will update when the database content is updated:

@HiltViewModel
class BannerViewModel @Inject constructor (
    // we should use repository rather than access BannerDao directly
    private val bannerDao: BannerDao
) : ViewModel() {

    val bannerList: Flow<BannerVO> = bannerDao.getAll().map {
        it.toVO()
    }
}

// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        bannerViewModel.bannerList.collectLatest {
            bannerAdapter.submitList(it)
        }
    }
}
Copy the code

3. Android Studio

Android is born to remains active Studio version update, the current version has been updated to the latest Bumblebee | 2021.1.1.21, Android since 4.3 Canary 1 Studio in naming style change, Better alignment with the IntelliJ platform version. In addition to the regular stable releases, developers can also get an early taste of new features through the RC and Preview versions.

With each release, the experience of writing and debugging code continues to improve, and more and more new features are integrated. Not to mention Layout Instpector, Device Exploer and so on, these new features are also great for development and debugging.

3.1 the Database Inspector

Room is used for data persistence, and the Database Inspector provides real-time access to Database files generated by the Jetpack Room framework, as well as real-time editing and deployment to the device. It is much more efficient and intuitive than the SQLite command or additional export and DB tool required before.

3.2 Realtime Profilers

The Realtime Profilers tool in Android Studio helps us monitor and detect problems in four areas, including in cases where engineering code is not available, and in cases where internal instance and variable details are visible through Memory Profilers.

  • CPU: Performance profiler checks CPU activity. Switching to Frames also allows interface lag tracking
  • Memory: Identify Memory leaks and jitter that can cause an application to stall, freeze, or even crash. You can capture heap dumps, enforce garbage collection, and track Memory allocations to locate Memory problems
  • Battery: Monitors CPU usage, network wireless devices, and GPS sensors, and visually displays how much power each component is consuming. Where is your application using unnecessary power
  • Network: Displays real-time Network activity, including sent and received data and the current number of connections. This allows you to examine how and when your application transfers data and optimize your code appropriately

3.3 APK Analyzer

Apk can consume network traffic to download and storage space to install. The size of its volume will affect App installation and retention, so it is particularly necessary to analyze and optimize its volume.

The APK Analyzer of AS can help to do the following:

  • Quick analysis of Apk components, including DEX, Resources, and Manifest Size and ratio, helps us optimize the direction of code or Resources
  • Diff Apk to understand the differences between versions and pinpoint the source of the increase in volume
  • Analysis of other APKs, including looking at general resources and analyzing code logic, further dismantling and Bug locating

3.4 DI Navigation

Dependency injection helps decouple modules and implements the design principle of separation of concerns. We use the Dagger/Hilt to hide the specific implementation through compile-time code generation, which reduces the cost of building the dependency diagram but also increases the cost of debugging the code for the developer: finding the source of the injected instance becomes difficult.

Android Studio now solves this pain point for developers. From 4.1 we can jump around the Dagger based code (e.g. Components, Subcomponents, Modules, etc.) to find the dependency.

The following icon can be seen next to the Dagger or hilt-related code:

Click the icon on the left to jump to where the instance object is provided, and click the icon on the right to jump to where the object is used. When there are multiple uses, a list of candidates will be given for selection.

Android 4.2 has also added dependency queries on @Enterpoint, and Hilt can be used to expand dependency injection for components that cannot be automatically injected, such as ContentProvider.

4. App Bundle

Android App Bundle is a package format developed by Google for dynamic distribution. When an application is uploaded to Google Play (or any other AAB-enabled marketplace) in AAB format, it can dynamically deliver features or resources as required.

Split APKs mechanism is the basis for AAB to realize dynamic delivery. After AAB uploads GP, it is Split into one base APK and multiple Split APKs. Only Base APKs are delivered for the first download, and Split APKs are dynamically delivered based on application scenarios. Split can make Configuration APKs, and can also be a Dynamic Features APKs:

  • The Configuration APKs: For example, res/drawable- xhdPI will be split into XHDPI Apk, res/values-en will be split into EN Apk. Configurations Changed requests the necessary resources when it occurs
  • Dynamic Features APKs: Features can be dynamically loaded on demand, which is similar to the popular “plug-in” technology in China. Functions can be loaded on demand by making some very useful functions into Dynamic Features.

Google attaches great importance to the promotion of AAB format. Since August 21, new apps must use AAB format to be available on Google Play. For a product to be put on the shelves overseas, we naturally choose AAB’s delivery method. In addition to the significant benefits in package volume, it also helps to promote the product and improve the installed rate.

4.1 Language Split

Our app is available in multiple countries at the same time, and we need to support English, Indonesian, Portuguese and other languages. With AAB, we can avoid downloading language resources from other countries.

Dynamic language delivery is very simple. First, enable language enableSplit in Gradle:

bundle {
    language {
        enableSplit = true
    }
}
Copy the code

When switching the system language, the application automatically downloads the required language through GP. Of course, you can also manually request language resources according to business requirements, for example, when you select another language in our built-in language switching interface:

private val _splitListener = SplitInstallStateUpdatedListener { state -> val lang = if (state.languages().isNotEmpty()) state.languages().first() else "" when (state.status()) { SplitInstallSessionStatus.INSTALLED -> { //... } SplitInstallSessionStatus.FAILED -> { //... } the else - > {}}} / / create SplitManager and register callback val SplitManager = SplitInstallManagerFactory. Create (requireContext ()) SplitManager. RegisterListener (_splitListener) / / install language resources val request = SplitInstallRequest. NewBuilder () .addLanguage(Locale.forLanguageTag(language)) .build() splitManager.startInstall(request);Copy the code

4.2 the Dynamic Feature

There are some advanced functions in the product, which not all users can use. For example, some advanced camera effects rely on a lot of SO and low-level libraries, which are made into Dynamic features to realize on-demand loading of functions:

Creating a Dynamic Feature is like creating a Gradle Module.

When creating DF, you can configure two download modes:

  • On-demand: indicates whether to enable dynamic delivery. If this parameter is selected, the Module is dynamically downloaded based on user requests. Otherwise, the Module will be installed when the Apk is installed
  • Fusing: This configuration is designed to accommodate devices below 5.0 that do not support AAB. If selected, the Module will be installed directly on devices below 5.0. Otherwise, the Module will not be included on devices below 5.0

Gradle/app/build.gradle

dynamicFeatures = [':dynamicfeature']
Copy the code

Request Dynamic features in required scenarios, similar to the request language code, using SplitInstallManager:

Val splitManager = SplitInstallManagerFactory. Create (requireContext ()) / / dynamic module installation SplitInstallRequest request = SplitInstallRequest .newBuilder() .addModule("FaceLab") .addModule("Avator") .build(); splitManager .startInstall(request) .addOnSuccessListener { sessionId -> ... } .addOnFailureListener { exception -> ... }Copy the code

4.3 Bundletool

AAB format can not be installed and debugable locally. Through the AAB > APK package tool provided by Google, we can compile it locally into APK, which is convenient for QA testing and developer self-testing.

The process for AAB to generate APK is as follows:.APks is generated in the process, and then.APK is generated for different devices.

Bundletool build-apks --bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks --ks=/MyApp/keystore.jks --ks-pass=file:/MyApp/keystore.pwd --ks-key-alias=MyKeyAlias --key-pass=file:/MyApp/key.pwd --device-spec=file:device-spec.jsonCopy the code

Generate a local Apk from device.json:

bundletool extract-apks
--apks=${apksPath}
--device-spec={deviceSpecJsonPath}
--output-dir={outputDirPath}
Copy the code

You can also install apK directly through APKS. At this point, apK is actually installed on the phone, but this command will automatically read the phone configuration, and then the corresponding APK is installed on the phone.

bundletool install-apks
--apks=/MyApp/my_app.apks
Copy the code

The volume of the final installation package was reduced by nearly 40% from 90M+ to 55M through language resources and Dynamic Feature delivery.

5. ML Kit

In addition to Jetpack’s libraries, Google also provided a number of other technical support for our application, such as the ML Kit. ML Kit is a mobile SDK launched by Google for mobile terminals. It supports Android and iOS platforms and encapsulates many machine learning capabilities such as text recognition, face position detection, object tracking and detection. For machine learning developers, The ML Kit also provides apis to help developers customize the TensorFlow Lite model. ML Kit also supports delivery at Google Play runtime to reduce package size.

As an AI special effects application, it needs to support users to select a certain face in multi-face pictures for rendering, so face detection capability is essential. After investigation, we choose ML Kit to achieve fast face detection.

ML Kit breaks down several machine learning capabilities, and the App only needs to introduce the required capabilities. Take face detection as an example, the Google Play dynamic delivery library for face detection is introduced, and the use of suspend function simplifies the use of API:

Dependencies {implementation 'com. Google. Android. GMS: play - services - mlkit - face - detection: 17.0.0'}Copy the code

Configure in the Androidmanifest.xml file:

<application ...>
        ...
    <meta-data
        android:name="com.google.mlkit.vision.DEPENDENCIES"
        android:value="face" />
</application>
Copy the code

Using coroutines provide suspendCancellableCoroutineAPI will callback into hang function

suspend fun faceDetect(input: Bitmap): List<Face> = suspendCancellableCoroutine { continuation ->
    val image = InputImage.fromBitmap(bitmap, 0)
    val detector = FaceDetection.getClient()
    detector.process(image)
        .addOnSuccessListener {
            continuation.resumeWith(Result.success(it))
        }
        .addOnFailureListener {
            continuation.resumeWithException(RuntimeException(it))
        }
        .addOnCanceledListener {
            continuation.cancel()
        }
}
Copy the code

The last

MAD has helped us achieve efficient product development and fast launch, and we will introduce Jetpack Compose in the future to further improve development efficiency and shorten the requirements iteration cycle. Due to limited space, the content of this paper is only a point, hoping to provide inspiration and reference for other similar offshore applications in the selection of technology.

As Jetpack continues to improve Google’s mobile development ecosystem, developers can focus more on business innovation and create richer features for the masses of users. The constant unification of underlying technologies also helps developers better carry out technical exchanges and construction, and get rid of the development dilemma of each fighting for itself and repeating the wheel.