Categories:
Search articles

Search for articles

Sorry, but we couldn't find any matches...

But perhaps we can interest you in one of our more popular articles?

Android app performance: Modular Architectures and Reusable Code (part 3)

Nov 18, 2020

How to build native Android apps on Codemagic

How to build native Android apps on Codemagic

Use codemagic.yaml file to keep your build configurations more organized and manage all your workflows
Read more
Codemagic builds and tests your app after every commit, notifies selected team members and releases to the end user. Automatically. Get started

Written by Sneh Pandya

Gathered while building multiple apps consisting of a vast variety of features and modules, this article is a summary of key learnings and best practices for modular application architecture. It is highly relevant for Android app development if you are aiming to achieve modularization or even white-label solutions.

Application Architecture

Based on your requirements, choose feature-based modules or functionality-based modules. Each module will have its own subdirectory and configuration.

Feature-Based Modules

If your app has features that are relatively independent of each other, then feature-based modules or folder structure will be the most suitable. In this case, it will also be helpful to ship individual features as modules only when the user wants to use them.

As shown in the image above, all the features have their own individual subdirectories. These subdirectories include complete configurations for each module or feature. Each of these modules can run independently as a mini-app.

To deliver a fully featured app, these modules are then injected into the base module, and their functionalities work seamlessly with each other while still maintaining separation internally.

In this project, here’s how the modules are injected in flow:

utilrepositoryrecipeplayer -> homeapp

util holds the utility code used across the project. It is injected into repository, which maintains network communication between data sources. repository is then injected into recipeplayer, which is a video playback module consisting of ExoPlayer. recipeplayer is injected into the home module, which holds views like the home screen, splash screen, and other navigational views for the app. home is injected into the app module, which includes Gradle-related configurations and scripts for app shrinking and performance.

// Dependencies for util
dependencies {
    // Kotlin
    implementation deps.kotlinplugin.ktx
    implementation deps.kotlinplugin.stdlib
}


// Dependencies for repository
dependencies {
    // Retrofit and OkHttp
    implementation deps.retrofit2.runtime
    implementation deps.retrofit2.gson
    implementation deps.okhttp3.core
    implementation deps.okhttp3.logginginterceptor

    // Firebase
    implementation deps.firebase.crash
    implementation deps.fabricplugin.crashlytics

    api project(':util')
}


// Dependencies for recipeplayer
dependencies {
    // ExoPlayer
    implementation deps.exoplayer2.exoplayer2

    // Both repository and util will be auto-injected
    api project(':repository')
}


// Dependencies for home
dependencies {
    // AndroidX
    implementation deps.androidx.appcompat
    implementation deps.androidx.material

    // Android Layouts
    implementation deps.constraintlayout.core

    api project(':recipeplayer')
}


// Dependencies for app
dependencies {
    api project(':home')
}

If we run only recipeplayer, all the resources injected so far will be initialized and ready to use, meaning the video player will run even without the home and app modules in debug mode.

To extend the use case, think of a situation where there are completely independent features named profile, cloudalbums, and chat. In this case, all three of the modules will be on the same hierarchy as recipeplayer since they do not directly depend upon recipeplayer. Simply inject repository into each one of them to make network logic and utility code accessible. Each of these modules will then be directly injected into home, making it seamless. This will allow you to push individual features independently of each other without affecting any other modules!

The configuration will then look like this:

// Dependencies for profile
dependencies {
    ...
    api project(':repository')
}


// Dependencies for cloudalbums
dependencies {
    ...
    api project(':repository')
}


// Dependencies for chat
dependencies {
    ...
    api project(':repository')
}


// Dependencies for home
dependencies {
    // AndroidX
    implementation deps.androidx.appcompat
    implementation deps.androidx.material

    // Android Layouts
    implementation deps.constraintlayout.core

    api project(':profile')
    api project(':cloudalbums')
    api project(':recipeplayer')

    // Enable when feature is completed and ready to ship
    // api project(':chat')
}

All the resource files are included in their respective modules and scoped to limited usage. This prevents the inclusion of unnecessary resources in the project. This approach maintains structural integrity for seamless API migrations, dependency management, functionality changes, or even obsolating/removing features from the app.

Functionality-Based Modules

On the other hand, if your app has functional or business logic that is complex and is associated with a larger part of your features, then functionality-based modules will be the most suitable. In this case, your individual functionalities will be injected into one or more of your final modules.

In this project, here’s how the modules are injected in flow:

utildatabasenetwork -> core -> homeapp

util holds the utility code used across the project and is injected into database, which maintains the database configuration. database is then injected into network, which provides network connection and data sources mapping. Next, network is injected into core, which is a video playback module consisting of ExoPlayer. core is injected into the home module, which holds views like the home screen, splash screen, and other navigational views for the app. home is injected into the app module, which includes Gradle-related configurations and scripts for app shrinking and performance.

// Dependencies for util
dependencies {
    // Kotlin
    implementation deps.kotlinplugin.ktx
    implementation deps.kotlinplugin.stdlib
}


// Dependencies for database
dependencies {
    // Room
    implementation deps.room.ktx

    api project(':util')
}


// Dependencies for network
dependencies {
    // Retrofit and OkHttp
    implementation deps.retrofit2.runtime
    implementation deps.retrofit2.gson
    implementation deps.okhttp3.core
    implementation deps.okhttp3.logginginterceptor

    // Firebase
    implementation deps.firebase.crash
    implementation deps.fabricplugin.crashlytics

    api project(':database')
}


// Dependencies for core
dependencies {
    // ExoPlayer
    implementation deps.exoplayer2.exoplayer2

    api project(':network')
}


// Dependencies for home
dependencies {
    // AndroidX
    implementation deps.androidx.appcompat
    implementation deps.androidx.material

    // Android Layouts
    implementation deps.constraintlayout.core

    api project(':core')
}


// Dependencies for app
dependencies {
    api project(':home')
}

The important thing to understand here is that all the features that you wish to add to your app will be added inside the single core module. The advantage of this approach is that you can keep your functionalities flexible—you can upgrade your network and database in a single place, and all the features will use the same code. Your features will remain lean and minimal and can be referenced inside a single module.

This approach helps when the code needs to be tightly coupled for the features to work closely with each other or when the features are partially or fully dependent on each other.

Reusable Utility Code

Some of the most commonly used code snippets are generalized and converted into utility code to increase reusability across the project.

Glide + Data Binding

Almost every application requires image loading, and loading images in reusable views is a little tricky with data binding. This code snippet shows how to use Glide for image loading with data binding. This is especially helpful in scenarios like loading images in RecyclerView items.

object BindingUtils {
    @JvmStatic
    @BindingAdapter("app:imageUrl", "app:gender")
    fun loadImage(view: AppCompatImageView, url: String, gender: String) {
        GlideApp.with(view.context)
            .load(url)
            .placeholder(
                when (gender) {
                    "male" -> R.drawable.ic_male_placeholder
                    else -> R.drawable.ic_female_placeholder
                }
            )
        .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
        .into(view)
    }
}

CircleImageView

A lot of apps often show images that are circle cropped. If you are using Glide for image operations, then simply use the .circleCrop() transformation as shown here. If you wish to create your own view that crops the image in a circular shape, the code below works best.

class CircleImageView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {

    var path = Path()

    var radius: Float = 0f
    var centerX: Float = 0f
    var centerY: Float = 0f

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        radius = measuredWidth / 2f
        centerX = measuredWidth / 2f
        centerY = measuredHeight / 2f

    }

    override fun onDraw(canvas: Canvas?) {
        path.addCircle(centerX, centerY, radius, Path.Direction.CW)
        canvas?.clipPath(path)
        super.onDraw(canvas)
    }

}

DiffUtil

DiffUtil calculates the difference between two lists and returns the output in the form of an updated list. It offers a huge performance improvement, as the algorithm calculating the differences is optimized for space and uses O(N) space to find the minimal number of addition and removal operations between the two lists.

This is especially useful when working with RecyclerView, List, etc., for refresh updates, filtered results, search results, and so on.

class UserDiffUtil(private val newList: ArrayList<User>? = null, private val oldList: ArrayList<User>? = null) : DiffUtil.Callback() {

    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList?.get(oldItemPosition)?.id == newList?.get(newItemPosition)?.id
    }

    override fun getOldListSize(): Int {
        return oldList?.size ?: 0
    }

    override fun getNewListSize(): Int {
        return newList?.size ?: 0
    }

    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList?.get(oldItemPosition) === newList?.get(newItemPosition)
    }
}

Network Connectivity Utility

class NetworkUtil {

    companion object {

        /**
         * Returns integer value for network type available
         *
         * @param context
         * @return network-type: 0 -> None, 1 -> Mobile, 2 -> WiFi
         */
        fun networkType(context: Context): Int {
            var result = 0 // Returns connection type. 0: none; 1: mobile data; 2: wifi
            val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                cm?.run {
                    cm.getNetworkCapabilities(cm.activeNetwork)?.run {
                        if (hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
                            result = 2
                        } else if (hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
                            result = 1
                        }
                    }
                }
            } else {
                cm?.run {
                    cm.activeNetworkInfo?.run {
                        if (type == ConnectivityManager.TYPE_WIFI) {
                            result = 2
                        } else if (type == ConnectivityManager.TYPE_MOBILE) {
                            result = 1
                        }
                    }
                }
            }
            return result
        }

        /**
         * Returns boolean value for network availability
         *
         * @param context
         * @return true -> network available, false -> network unavailable
         */
        fun isNetworkAvailable(context: Context): Boolean {
            return networkType(context) != 0
        }
    }
}

Bonus: Google Play Policy Updates

Google Play policies are constantly evolving to improve the user experience and create a safer ecosystem. It is essential to comply with these policies and updates to be able to publish your Android apps on Google Play.

  1. 64-bit device architecture support for Android apps has been mandatory as of August 01, 2020. This means apps must support 64-bit ABIs for their existing 32-bit architecture versions. Games built with Unity 5.6.7 and lower will have to comply by August 01, 2021 at the latest. Here’s how to verify.

  2. Starting August 02, 2021, all new apps using Play Billing must use Billing Library version 3 or newer. By November 01, 2021, all updates to existing apps must use Billing Library version 3 or newer. Here’s how to migrate.

  3. By August 03, 2020, all new apps must target at least Android 10 (API 29). By November 02, 2020, all app updates must target at least Android 10 (API 29). Until then, new apps and app updates must target at least Android 9 (API 28). Here are the API differences that may impact your Android features.

  4. Starting November 01, 2020, all apps that use in-app subscriptions must support Account Hold and Restore. At the same time, all subscriptions will be automatically opted into Pause and Resubscribe unless these features are proactively turned off in Google Play Console. Here’s a guide for this.

  5. Between September 30, 2020, and January 18, 2021, newly submitted apps that access background location will need approval before the apps are made live. All apps published prior to September 30, 2020, that access location in the background must have approval by March 29, 2021, or they will be removed from Google Play. Here’s a guide for this.

Conclusion

Maintaining and scaling mobile apps becomes easy once we use the approach that is right for specific apps, rather than forcing apps to adapt to a certain architecture or approach. While the application architectures described above are versatile and adaptable, there are a lot of other possible variations as well. To choose the right one, follow the tips below:

1. Know your features well—in detail

This will help you decide how to build your own application architecture. By understanding what your app has to offer, you will be able to decide how to decouple its components for reusability.

2. Decide which app components will need constant updates or upgrades

This can be a specific feature, a group of features, or even particular framework dependencies. Separate your components based on how frequently they will evolve. For ever-evolving features, like displaying e-commerce offers, the first approach shown above would be more suitable.

3. Refer to Android’s Application Architecture documentation

This will help you decide how to structure your files, business logic, utilities, and more apart from the structure mentioned in the steps above.

4. Avoid over-engineering apps

A lot of developers tend to get carried away by fancy library and framework changes, which might not always be the solution. For apps that are not feature-heavy, the rule of thumb is to use a minimal number of libraries and keep the app architecture as simple as possible.

5. Know the technical debt of your apps

It is crucial to understand the impact of your actions on your project, especially the code you write. It is wise to aim for an optimum balance between feature implementation and code refactoring. In most cases, if the apps do not have a pool of features, keeping the separation to a minimum level works best since you do not have to re-engineer everything when the functionalities change.


Sneh is a Senior Product Manager based in Baroda. He is a community organizer at Google Developers Group and co-host of NinjaTalks podcast. His passion for building meaningful products inspires him to write blogs, speak at conferences and mentor different talents. You can reach out to him over Twitter (@SnehPandya18) or via email (sneh.pandya1@gmail.com).

How did you like this article?

Oops, your feedback wasn't sent

Latest articles

Show more posts