Learn the best practices of modern Android development in one article

Learn the best practices of modern Android development in one article

Author: Wang Peng, Sun Yongsheng

What is MAD?

​​https://developer.android.com/series/mad-skills​​

The full name of MAD is Modern Android Development, which is a collection of technology stacks and tool chains, covering every aspect from programming languages ​​to development frameworks.

Android SDK has not changed much in the years since its birth in 2008, and the development method is relatively fixed. Since 2013, the technology update has gradually accelerated, especially after 2017, with the emergence of new technologies such as Kotlin and Jetpack, the Android development method has changed greatly. Jetpack Compose launched last year has pushed this change to a new stage. Google named the development method under these new technologies MAD to distinguish it from the old inefficient development method.

MAD can guide developers to develop excellent mobile applications more efficiently. Its advantages are mainly reflected in the following points:

  • Trustworthy: Google has more than ten years of cutting-edge development experience in the Android industry.
  • Beginner-friendly: Provides a large number of demos and reference documents, suitable for projects of different stages and sizes
  • Efficient startup: You can quickly build your project with frameworks such as Jeptack and Jetpack Compose
  • Free choice: The framework is rich and diverse, and can be freely combined with traditional languages, native development, and open source frameworks
  • Consistent experience: The development experience is consistent across different devices and versions

MAD helps applications go global

Recently, we have completed the launch of an AI special effects application on Google Play. This application can process the user's own avatar pictures into various artistic effects through algorithms. The application has been well received as soon as it was launched. This is all thanks to our comprehensive application of MAD technology in the project. We completed the entire development in the shortest time and created an excellent user experience.

Under the guidance of MAD, the code architecture of the project is more reasonable and maintainable. The following figure shows 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 recognized by Android. In our project, all codes are developed using Kotlin. Kotlin's syntax is very concise, and the code size of the same function in Java can be reduced by 25%. In addition, Kotlin also has many excellent features that Java does not have:

1.1 Safety

Kotlin has many excellent designs in terms of safety, such as null safety and data immutability.

Null Safety

Kotlin's null safety feature allows many runtime NPEs to be exposed and discovered in advance during the compilation period, effectively reducing the occurrence of online crashes. We attach importance to the judgment and processing of Nullable types in the code. When defining data structures, we strive to avoid nullable types to minimize the cost of null judgment;

 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 in advance
fun < DATA > ISelectedStateController < DATA > . getSelectState ( data : DATA ): SelectedState {
return getStateOrNull ( data ) ? : SelectedState . NON_SELECTED
}

In the Java era, we can only use naming conventions such as getStateOrNull to remind people that return values ​​can be nullable. Kotlin uses ? to help us better understand the risks of Nullable. We can also use the Elvis operator ?: to convert Nullable to NonNull for subsequent use. Kotlin's !! makes it easier for us to discover the potential risks of NPE and use static checks to give warnings.

Kotlin's default parameter value feature can also be used to prevent NPE. For structure definitions like the one below, you don't have to worry about Null appearing 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
)

After we fully embraced Kotlin, the NPE crash rate was only 0.3‰, while the NPE of Java projects usually exceeded 1‰.

Immutable

The security of Kotlin is also reflected in the fact that data cannot be modified at will. We use data classes extensively in the code and require attributes to be defined using val rather than var, which is conducive to the promotion of the one-way data flow paradigm in the project and the separation of data reading and writing 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 > ()
}

As above, we use data class to define UiState for use in ViewModel. The val declaration property ensures the immutability of State. Using a sealed class to define Result is conducive to enumerating various request results and simplifying the logic.

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

_uiState . value =
_uiState . value . copy ( bannerList = Result . Success ( it ))

When the 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 reflected in the type of collection class. In our project, we advocate not to use Mutable types such as MutableList unless necessary, which can reduce the occurrence of multi-threading problems such as ConcurrentModificationException, and more importantly, avoid data consistency problems caused by Item tampering:

 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 )
}

For example, in the above example, after the UI side receives the UiState update notification, it submits DiffUtil to refresh the list. The basis for the normal operation of DiffUtil is that mList and newList can always maintain the Immutable type.

1.2 Functional

Functions are first-class citizens in Kotlin and can be used as parameter or return value types to form higher-order functions. Higher-order functions can provide easier-to-use APIs in scenarios such as collection operators.

Collection operations

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

In the above code, we sort and filter BannerModelList in turn and convert it into a list of BannerImageItem type. The use of set operators makes the code smooth.

Scope functions

Scope functions are a series of inline higher-order functions. They can be used as glue for code, reducing the appearance of redundant 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 ()
}

When we create and start a Fragment, we can complete various initialization tasks based on the scope function, as shown in the example above. This example also reminds us that excessive use of these scope functions (or collection operators) will also affect the readability and debuggability of the code. Only the "appropriate" use of functional programming can truly bring out the advantages of Kotlin.

1.3 Corroutine

Kotlin coroutines free developers from callback hell. At the same time, the characteristics of structured concurrency also help to better manage subtasks. Various native libraries and third-party libraries of Android are beginning to turn to Kotlin coroutines when handling asynchronous tasks.

Suspend function

In the project, we advocate the use of suspend functions to encapsulate asynchronous logic. It goes without saying that Room or Retorfit use suspend function style APIs in the data layer. Some presentation layer logic can also be implemented based on suspend 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 ))
}

In the above example, doShare uses a suspend function to handle the logic of sharing photos: a sharing panel pops up for the user to select a sharing channel, and the sharing result is returned to the caller. The caller starts sharing and synchronously obtains the result of sharing success or failure. The coding style is more intuitive.

Flow

In the project, Flow is used instead of RxJava to process streaming data. While reducing the package size, CoroutineScope can effectively avoid data leakage:

 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 ()

The above example is used to obtain BannerList from multiple data sources. We have added a disk cache strategy to request local database data first, then request remote data. The use of Flow can well meet this type of scenario involving multiple data source requests. On the other hand, on the calling side, as long as a suitable CoroutineScope is provided, there is no need to worry about leaks.

1.4 KTX

Some Android libraries originally implemented in Java provide extended APIs for Kotlin through KTX, making them easier to use in Kotlin projects.

Our project uses Jetpack Architecture Components to build the App infrastructure. KTX helps us greatly reduce the API usage cost in Kotlin projects. Here are some of the most common KTX examples:

fragment-ktx

fragment-ktx provides some Kotlin extension methods for Fragment, such as the creation of ViewModel:

 class HomeFragment : Fragment () {
private val homeViewModel : HomeViewModel by viewModels ()
...
}

Compared with Java code, creating ViewMoel in Fragment becomes extremely simple. The reason behind this is that it actually makes use of various Kotlin features, which is very clever.

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

viewModels is an inline extension method of Fragment. It uses the reified keyword to obtain the generic type at runtime to create a specific 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 )
}

createViewModelLazy returns a Lazy instance, so we can create a ViewModel using the by keyword. Here, Kotlin's proxy feature is used to implement lazy instance creation.

viewmodle-ktx

viewModel-ktx provides extension methods for ViewModel, such as viewModelScope, which can terminate expired asynchronous tasks in time with the destruction of ViewModel, allowing ViewModel to be used more safely as a bridge between the data layer and the presentation layer.

 viewModelScope . launch {
//Monitor data from the data layer
repo.getMessage () . collect {
//Send a message to the presentation layer
_messageFlow . emit ( message )
}
}

The implementation principle is also 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 ))
}

viewModelScope is essentially an extended property of ViewModle. When creating CloseableCoroutineScope through custom get, it is recorded in the position of JOB_KEY.

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

override fun close () {
coroutineContext.cancel ( )
}
}

CloseableCoroutineScope is actually a Closeable. When onClear of ViewModel is called, it searches for JOB_KEY and close is called to cancel SupervisorJob and terminate all sub-coroutines. KTX makes use of various features and syntax sugar of Kotlin. You will see more usage of KTX in the Jetpack chapter later.

2. Android Jetpack

Android provides developers with basic capabilities on top of AOSP through Jetpack, covering all levels from UI to Data, reducing the need for developers to reinvent the wheel. Recently, the architecture specifications of Jetpack components have been fully upgraded to help us better implement the design goal of separation of concerns during the development process.

2.1 Architecture

Android advocates the architectural design of separating the presentation layer from the data layer, and uses unidirectional data flow to complete data communication. Jetpack supports the implementation of UDF in Android through a series of Lifecycle-aware components.

The main features and advantages of UDF are as follows:

  • Single Source of Truth (SSOT): UI State is centrally managed in ViewModel, reducing the synchronization cost between multiple data sources
  • Data flows from top to bottom: UI updates are based on VM state changes, and the UI itself does not hold state and is not coupled to business logic
  • Events are passed from bottom to top: UI sends events to VM to modify the status centrally. Status changes can be traced back, which is conducive to unit testing.

All business scenarios involving UI in the project are built based on UDF. Take HomePage as an example, it includes two sets of data display, BannerList and ContentList, and all data are centrally managed 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 ))
)
}
}
}

}
}

As shown in the above code, HomeViewModel gets data from Repo and updates UiState, and View subscribes to this state and refreshes the UI. The CoroutineScope provided by viewModelScope.launch can end the running coroutine with the onClear of ViewModel to avoid leakage.

In the data layer, we use the Repository Pattern to encapsulate the specific implementation of local data sources 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" )
}
}

Take getBannerList as an example. First, local data is requested from the database to speed up the display. Then, the remote data source is requested to update the data and persist it for the next request.

The logic of the UI layer is very simple, just subscribe to the data of ViewModel and refresh the UI. We use Flow instead of LiveData to encapsulate UiState, lifecycleScope makes Flow a Lifecycle-aware component; repeatOnLifecycle makes Flow automatically stop the emission of data flow when the Fragment switches between the foreground and the background like LiveData, saving resource overhead.

2.2 Navigation

As a practitioner of the "single activity architecture", we chose to use Jetpack Navigation as the navigation component of the App. The Navigation component implements the navigation design principles, provides a consistent user experience for switching across applications or between pages within an application, and provides various advantages, including:

  • Handle Fragment transactions;
  • By default, round-trip operations are handled correctly;
  • Provide standardized resources for animations and transitions;
  • Implementing and handling deep links;
  • Includes navigation interface patterns (such as navigation drawers and bottom navigation) with minimal additional work on the developer's part;
  • Provide a Gradle plugin to ensure type safety when passing parameters on different pages;
  • Provides a navigation graph-scoped ViewModel to share data between pages in the same navigation graph;

Navigation provides two configuration methods: XML and Kotlin DSL. We take advantage of Kotlin in our project, create a navigation graph based on a type-safe DSL, and use function extraction to specify transition animations for the page:

 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 ()
}
}
}
}
}

//Uniformly specify transition animation
internal fun NavOptionsBuilder . applySlideInOut () {
anim {
enter = R . anim . slide_in
exit = R . anim . slide_out
popEnter = R . anim . slide_in_pop
popExit = R . anim . slide_out_pop
}
}

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 ()
}
}
}

In Fragment, use findNavController() provided by navigation-fragment-ktx to correctly redirect pages based on the current Destination at any time:

 @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 )
}
}
}

In addition, we can declare global page navigation, which is very useful in guiding users to log in, register, or go to the feedback page:

 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 ()
}
}
}
}

2.3 Hilt

Dependency Injection is a commonly used technology in multi-module projects. As a way to implement the inversion of control design principle, dependency injection is conducive to the decoupling of the production side and the consumption side of the instance, practicing the design principle of separation of concerns, and is also more conducive to the writing of unit tests.

Hilt is built on the basis of Dagger. It inherits the advantages of Dagger such as compile-time checking, high performance at runtime, and scalability, while providing a more friendly API, which greatly reduces the cost of using Dagger. Android Studio also has built-in support for Dagger/Hilt, which will be introduced later.

Hilt is widely used in the project to complete dependency injection, which further improves 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 @ApplicationContext annotation to pass in the application-level Context. Only @Inject is needed to inject objects where needed:

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

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

recommendRepository.doSomeThing ( )
}
}

For some third-party library classes that cannot be annotated in the constructor, we can use @Provides to tell Hilt how to create related instances. For example, provide the implementation of creating the Retrofit 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 )
}
}

Thanks to Hilt's support for other Jetpack components, you can also use Hilt for dependency injection in ViewModel or WorkManager.

 @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 ()
)
}

2.4 WorkManager

WorkManager is a Jetpack library designed for persistent work, which refers to tasks that can be performed continuously across application or system restarts, such as synchronizing application data with the server, or uploading logs. WorkManager automatically selects FirebaseJobDispatcher, GcmNetworkManager, or JobScheduler to perform scheduling tasks based on the strategy, and provides a simple and consistent API for easy use.

WorkManager uses the Jetpack StartUp library for initialization by default. Developers only need to focus on defining and implementing Workers without any additional work. WorkManager is backward compatible to Android 6.0 and covers most models on the market. It can effectively replace Service to complete background tasks that require long-term execution.

In order to reduce the time and traffic consumption required for users to upload pictures when generating avatars, the product will compress the pictures before uploading. However, the temporary files in the compression process will increase the storage space occupied by the App. Therefore, we use WorkManager to schedule the work of clearing the compressed picture cache and submit the task to WorkManager after the App is started:

 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 ()
}
}
}

Another scenario is when a user downloads an image. Downloading requires a network connection, and this task has a higher priority, so you can use the work constraints and expedited work (WorkManager 2.7 and above) provided by WorkManager. In addition, you can monitor the result information of the task to prompt the user:

 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 - > {}
}
}
}
}

2.5 StartUp

When an application starts, a lot of initialization work needs to be done, such as SDK initialization, basic module configuration, etc. Before StartUp appeared, we used ContentProvider to complete "non-invasive" initialization to avoid the appearance of init(Context) and other codes in Application. However, the creation cost of ContentProvider is high, and the creation of multiple ContentProviders at the same time will slow down the startup speed of the application and the initialization sequence is uncontrollable.

StartUp only uses one ContentProvider to complete the initialization of multiple components, which solves the various problems of the above ContentProvider. In addition, StartUp can also avoid unnecessary dependencies of the app module on other modules. For example, we need to rely on a separate Module for the local test channel in the project. This Module relies on Context to complete initialization, but we don't want it to be included in the release package. At this time, just add the Gradle dependency as shown below, and the app does not need to rely on the local_test module at the code level.

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

The StartUp library is very simple to use. 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 )
}
}

In the above example, the initialization of the Account module will wait until the initialization of the Server module is complete before continuing.

2.6 Room

Apps with local-first architecture can provide a good user experience. When the device cannot access the network, users can still browse the corresponding content offline. Android provides SQLite as an API to access the database, but the SQLite API is relatively low-level and requires manual verification of the correctness of the SQL statement. In addition, a large amount of template code needs to be written to complete the conversion between PO and DO. Jetpack Room provides an abstraction layer based on SQLite to help developers access the database more smoothly.

Room mainly consists of three components: Database is the database holder and the main access point to the underlying database; Entity represents the table in the database; DAO contains methods for accessing the database. The three components are declared through 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
}

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

 @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 ()
}

}

When the data in the database is updated, we hope that the UI will be automatically refreshed. Thanks to Room's good support for Coroutine and RxJava, we only need to introduce the room-ktx library or room-rxjava2/3 library. The methods in DAO can also directly return Flow or Observable, or directly use suspend functions:

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

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

At this time, we only need to subscribe to Flow at the UI layer, so that the UI can be updated 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 )
}
}
}

Furthermore, in the UI layer, we only subscribe to the data in the database, and use WorkManager to initiate a network request in the background, and then write the latest data into the database after obtaining the data. Since the database access speed is much faster than the network, the page can be presented to the user faster.

3. Android Studio

Android Studio has been actively updated since its birth. The latest version has been updated to Bumblebee | 2021.1.1.21. Since 4.3 Canary 1, Android Studio has adjusted its naming style to better align with the IntelliJ platform version. In addition to the regularly released stable version, developers can also experience more new features in advance through the RC and Preview versions.

With the continuous update of the version, the experience of writing and debugging code has been continuously optimized, and more and more new features have been integrated. Needless to say, the existing functions such as Layout Instpector and Device Explorer, the following new features also provide great convenience for our development and debugging.

3.1 Database Inspector

We use Room for data persistence. Database Inspector can view the database files generated by the Jetpack Room framework in real time, and also supports real-time editing and deployment to devices. Compared with the previous SQLite command or additional export and DB tool method, it is more efficient and intuitive.

3.2 Realtime Profilers

Android Studio's Realtime Profilers tool can help us monitor and discover problems in the following four aspects. Sometimes, in the absence of project code, Memory Profilers can also be used to view its internal instance and variable details.

  • CPU: Performance profiler to check CPU activity, switch to Frames view to track interface freezes
  • Memory: Identify memory leaks and memory thrashing that may cause your app to freeze, freeze, or even crash. Capture heap dumps, force garbage collection, and track memory allocations to locate memory issues.
  • Battery: Monitors CPU, network radio, and GPS sensor usage and visually displays the power consumed by each component, helping you understand where apps are using unnecessary power
  • Network: Displays real-time network activity, including data sent and received and the current number of connections. This allows you to examine how and when your app transfers data and optimize your code appropriately.

3.3 APK Analyzer

The download of APK will consume network traffic, and the installation will also take up storage space. Its size will affect the installation and retention of App, so it is particularly necessary to analyze and optimize its size.

With the help of AS's APK Analyzer, you can complete the following tasks:

  • Quickly analyze the composition of the Apk, including the size and proportion of DEX, Resources and Manifest, to help us optimize the direction of code or resources
  • Diff Apk to understand the differences between the previous and next versions and pinpoint the source of the increase in size
  • Analyze other APKs, including checking the general resources and analyzing the code logic, and then disassemble and locate bugs

3.4 DI Navigation

Dependency injection helps decouple modules and implements the design principle of separation of concerns. We use Dagger/Hilt to hide the relevant specific implementation through compile-time code generation, which reduces the cost of building dependency graphs while increasing the cost of debugging code for developers: it becomes difficult to find the source of the injected instance.

Now Android Studio helps developers solve this pain point. Since 4.1, we can jump in Dagger-based code (such as Components, Subcomponents, Modules, etc.) to find dependencies.

You can see the following icon next to the Dagger or Hilt related code:

Click the icon on the left to jump to the provision of the instance object, and click the icon on the right to jump to the usage of the object. When there are multiple uses, a candidate list will be given for selection.

Starting from Android 4.2, dependency queries on @EnterPoint have been added. For components such as ContentProvider that cannot be automatically injected, the scope of use of dependency injection can also be expanded through Hilt.

4. App Bundle

Android App Bundle is a packaged format for dynamic distribution launched by Google. When an application is uploaded to Google Play (or other AAB-enabled application market), it can implement dynamic distribution of functions or resources as needed.

The Split APKs mechanism is the basis for AAB to implement dynamic distribution. After uploading GPs, AAB is split into a base APK and multiple Split APKs. Only Base APKs are issued for the first download, and then dynamically issue Split APKs according to the usage scenario. Split can enable Configuration APKs or a Dynamic Features APKs:

  • Configuration APKs: Split resources according to language, density, and abi. For example, res/drawable-xhdpi will be split into the apk of xhdpi, res/values-en will be split into the apk of en, and when Configurations Change occurs, necessary resources will be requested.
  • Dynamic Features APKs: It can realize on-demand dynamic loading of Features, which is similar to the popular "plugin" technology in China. By making some very useful functions into Dynamic Features, it can realize on-demand loading of features.

Google attaches importance to the promotion of AAB format. Since August 21, it has stipulated that new apps must use AAB format to be listed on Google Play. As a product to be launched overseas, we naturally chose the delivery method of AAB. In addition to the significant benefits in package volume, it has also helped to improve product promotion and installation opportunities.

4.1 Language Split

Our application is available at the same time in multiple countries and needs to support multiple languages ​​such as English, Indonesian, and Portuguese. With the help of AAB, we can avoid downloading language resources from other countries.

It is very simple to issue a language dynamically. First, enable the language enableSplit in Gradle:

 bundle {
language {
enableSplit = true
}
}

When switching system languages, the application will automatically download the required language through GP. Of course, you can also manually request language resources according to business needs, such as when selecting other languages ​​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 - > {
//...
}
else - > {}
}
}

//Create SplitManager and register callback
val splitManager = SplitInstallManagerFactory . create ( requireContext ())
splitManager . registerListener ( _splitListener )


//Installing language resources
val request = SplitInstallRequest . newBuilder ()
. addLanguage ( Locale . forLanguageTag ( language ))
. build ()
splitManager . startInstall ( request );

4.2 Dynamic Feature

There are some advanced features in the product that not all users will use, such as some advanced camera effects, but rely on more so and underlying libraries to make them into on-demand loading of the Dynamic Feature implementation functions:

Creating a Dynamic Feature is like creating a Gradle Module.

There are two ways to download DF when creating:

  • on-demand: Whether to send dynamically, if checked, it means to download dynamically according to user requests. Otherwise, the Module will be installed when the user installs Apk.
  • fusing: This configuration is mainly for compatibility with the situations below 5.0 and does not support AAB. If checked, the Module will be installed directly on the devices below 5.0. Otherwise, the Module will not be included in the devices below 5.0.

After DF is created, response registration will be added in app/build.gradle:

 dynamicFeatures = [ ':dynamicfeature' ]

Requesting Dynamic Feature in the required scenario, similar to the code in the request language, both use SplitInstallManager:

 val splitManager = SplitInstallManagerFactory . create ( requireContext ())

//Dynamic installation of module
SplitInstallRequest request =
SplitInstallRequest
. newBuilder ()
. addModule ( "FaceLab" )
. addModule ( "Avator" )
. build ();

splitManager
. startInstall ( request )
. addOnSuccessListener { sessionId - > ... }
. addOnFailureListener { exception - > ... }

4.3 Bundletool

The AAB format cannot be installed and debugged locally. Through the AAB > APK packaging tool provided by Google, we can compile it locally into APK, which is convenient for QA testing and developers' self-testing.

The process of generating APKs in AAB is as follows. .apks will be generated in the middle, and then specific .apks will be generated for different devices.

 // Generate apks file through aab
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 . json

Generate local Apk via device.json:

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

You can also install it directly through apks. At this time, you actually install the apk on the phone. However, this command will automatically read the phone configuration, and then create the corresponding apk, and then install it on the phone.

 bundletool install - apks
-- apks = /MyApp/ my_app . apks

The final installation package is issued through language and other resources and dynamics of Dynamic Feature, and the package volume is reduced by nearly 40%, compressed from 90M+ to 55M.

5. ML Kit

In addition to Jetpack's related class libraries, Google also provides a lot of other technical support for our applications, such as ML Kit. ML Kit is a mobile SDK launched by Google for mobile devices. 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, ML Kit also provides an API to help developers customize the TensorFlow lite model. ML Kit also supports Google Play runtime issuance to reduce the volume of the package.

As an AI special effects application, it is necessary to support users to select a face in multiple face pictures for rendering, so face detection capabilities are essential. After research, we chose ML Kit to achieve fast face detection.

ML Kit splits several machine learning capabilities, and the app only needs to introduce the required capabilities. Taking face detection as an example, it introduces face detection Google Play dynamically releases libraries, and uses suspend functions to simplify the use of APIs:

 dependencies {
implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.0.0'
}

Configure in the AndroidManifest.xml file:

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

Use the suspendCancellableCoroutineAPI provided by the coroutine to transform the callback into a suspend 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 ()
}
}

at last

MAD has helped us complete the efficient development and rapid launch of our products. In the future, we will also introduce Jetpack Compose to further improve development efficiency and shorten the iteration cycle of demand. Due to space limitations, the content in the article is only a point, hoping to provide inspiration and reference for other similar overseas applications in technology selection.

With the continuous improvement of the Google mobile development ecosystem represented by Jetpack, developers can focus more energy on business innovation and develop more rich functions for the majority of users. The continuous unification of underlying technologies will also help developers better carry out technical exchanges and co-construction, and get rid of the development dilemma of fighting each other and re-creating wheels.

<<:  Google is testing variable refresh rate (VRR) support for Chrome OS

>>:  The M1 chip successfully runs Linux, and the terminal starts the installation with one line of code, and can also delete and uninstall with one click

Recommend

Intelligent Question Answering: BERT-based Semantic Model

Author: Luo Zijian background Feishu Intelligent ...

What are SEM search engine marketing tools?

Baidu Promotion Client is a backend account manag...

Coca-Cola Marketing Law

When you see this billboard , can you recognize w...

Analysis of advertising placement on Xiaohongshu & Zhihu

On March 5, Zhihu officially submitted its prospe...

If these 3 points are not clear, even the marketing gods will fail!

The simplest logic for marketing is to ask yourse...