An app developer should understand the importance of creating a smooth, responsive, and efficient app.
With millions of apps on the Google Play Store, users have come to expect a high level of performance, and any subpar experience can lead to negative reviews or, worse, uninstalls.
To help you stay ahead in this competitive landscape, we've put together a comprehensive guide on android app performance optimization.
In this article series, we will delve into the key areas to focus on to improve your app's performance, including code optimization, UI/UX enhancements, network usage optimization, multithreading, storage, battery usage, and more. We will also discuss the importance of utilizing Android Jetpack libraries, keeping your app size small, and maintaining up-to-date libraries and SDKs.
Code optimization is crucial for ensuring your app runs smoothly and efficiently. Let’s look at some ways of achieving performance using code optimisation
Optimize Code
Use the Android Profiler to monitor and identify performance bottlenecks
The Android Profiler is a powerful tool integrated into android studio that helps track your app's performance in real-time. It monitors CPU, memory, and network usage, and identify bottlenecks that may be slowing down the app. By addressing these bottlenecks, you can significantly improve app's performance. However this can be sometimes resource-intensive and might slow down your development environment.
Reduce object allocations to avoid garbage collection
Minimizing object allocations reduces garbage collection events, leading to fewer performance hiccups. In Kotlin, you can use the extension functions like apply
, also
, let
, and with
to avoid creating unnecessary temporary objects. Also, consider using the object keyword for singletons and avoiding anonymous inner classes that can lead to memory leaks.
See example:
// Instead of creating a new object each time, use a singleton.
object Singleton {
fun doSomething() { /*...*/ }
}
// Use extension functions to avoid temporary objects.
val modifiedList = myList.map { it * 2 }.filter { it > 10 }
Use the appropriate data structures and algorithms
Selecting the right data structures and algorithms can significantly impact your app's performance. For example, if you need to look up items by their keys, using a HashMap instead of a List can speed up your app:
val itemsMap: HashMap<String, Item> = hashMapOf()
// Add items to the map
itemsMap["itemKey"] = item
// Retrieve items using their keys
val retrievedItem = itemsMap["itemKey"]
Avoid memory leaks by managing resources carefully
Memory leaks occur when you hold references to objects that are no longer needed. This can lead to increased memory usage and ultimately cause your app to crash. You can use the use function to automatically close resources like streams and avoid memory leaks.
// Use 'use' to automatically close resources.
FileInputStream("file.txt").use { inputStream ->
// Process the file.
}
Consider using native code (NDK) for performance-critical tasks
For computationally intensive tasks, you might benefit from writing native code using the Android NDK. Kotlin/Native allows you to write platform-specific code that can be called from your Kotlin code. However, keep in mind that using native code increases complexity and should only be considered when necessary.
// Use the 'kotlinx.cinterop' package to interact with C libraries.
import kotlinx.cinterop.*
import platform.posix.*
fun readNativeFile() {
val file = fopen("file.txt", "r") ?: throw Error("Cannot open file")
try {
// Read the file using native C functions.
} finally {
fclose(file)
}
}
UI/UX
A smooth and efficient user interface is essential for a great user experience. There are several techniques to achieve this.
Optimize layouts with Android Studio's Layout Inspector and Hierarchy Viewer
These tools help you analyze and optimize your app's layouts. By simplifying view hierarchies, you can reduce overdraw and improve rendering performance. However this may be a little time consuming.
Minimize overdraw by simplifying view hierarchies and using background colors
Overdraw occurs when multiple layers are drawn over each other, causing unnecessary rendering work. You can minimize overdraw by flattening your layouts and setting backgrounds only when needed
// Good practice: Use a single background for the parent layout
<LinearLayout
android:background="@color/background"
...>
<TextView .../>
<TextView .../>
</LinearLayout>
// Bad practice: Multiple backgrounds cause overdraw
<LinearLayout ...>
<TextView
android:background="@color/background"
.../>
<TextView
android:background="@color/background"
.../>
</LinearLayout>
Use AndroidX
The RenderScript API has been deprecated in Android 12, and it is recommended to migrate to the AndroidX library for hardware-accelerated computations. The androidx.core:graphics library provides classes and methods for performing computationally intensive tasks like image processing.
Other options like using Vulkan API or OpenGL ES can also be explored. It's important to thoroughly test the app after making significant changes.
Optimize image resources by using appropriate formats and resolutions
Choose the right image format (e.g., WebP, JPEG, or PNG) and resolution for your app to reduce memory usage and loading times. Android Studio has a built-in image asset importer that helps generate different resolutions for your images
Use placeholder content or skeleton loading to enhance perceived performance
While your app fetches data or loads resources, use placeholders or skeleton loading to give users visual feedback, making the app feel more responsive
val imageView: ImageView = findViewById(R.id.my_image_view)
Glide.with(this)
.load(imageUrl)
.placeholder(R.drawable.placeholder_image)
.into(imageView)
Network usage
Efficient network usage is crucial for providing a smooth user experience, especially on slow or unreliable connections. Let’s see what techniques we can put in action for this
Use caching and local storage to reduce network requests
Caching data locally can help reduce network requests and improve app performance. For example, you can use Android's SharedPreferences or Room library to cache data, however this might result in in increased local storage usage.
See example:
// Save data to SharedPreferences
val sharedPreferences = getSharedPreferences("MyAppCache", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("key", "value")
editor.apply()
// Retrieve data from SharedPreferences
val cachedValue = sharedPreferences.getString("key", null)
Use background tasks and services for network operations
Running network operations in the background can help keep your app responsive. For example, you can use Kotlin coroutines for asynchronous tasks:
import kotlinx.coroutines.*
fun fetchData() {
CoroutineScope(Dispatchers.IO).launch {
val data = apiCall() // Make a network request
withContext(Dispatchers.Main) {
updateUI(data) // Update the UI with the fetched data
}
}
}
Use data compression techniques to reduce payload size
Compressing data before sending it over the network can help reduce payload size and improve app performance. You can use libraries like GZIP or Brotli to compress and decompress data. but this requires requires additional processing for compression and decompression.
See example:
// Request compressed data using OkHttp
val client = OkHttpClient.Builder()
.addInterceptor {
val request = it.request().newBuilder()
.header("Accept-Encoding", "gzip")
.build()
it.proceed(request)
}
.build()
Monitor network connectivity and adapt app behaviour accordingly
Adapting your app's behaviour based on network connectivity can improve user experience. You can use the ConnectivityManager to detect network changes.
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
// Network is available; update app behavior accordingly
}
override fun onLost(network: Network) {
// Network is lost; update app behavior accordingly
}
}
connectivityManager.registerDefaultNetworkCallback(networkCallback)
Use Firebase Cloud Messaging (FCM) for efficient push notifications
Leverage FCM to deliver push notifications with minimal battery and network impact.
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Handle the received push notification
}
}
Multithreading
Efficient use of multiple threads can help you execute tasks concurrently, leading to a smoother and more responsive app. Here are some essential techniques
Use Kotlin Coroutines for asynchronous tasks
Kotlin coroutines allow you to write asynchronous code in a more readable and efficient way.
Example:
import kotlinx.coroutines.*
fun fetchData() {
CoroutineScope(Dispatchers.IO).launch {
val data = apiCall() // Make a network request
withContext(Dispatchers.Main) {
updateUI(data) // Update the UI with the fetched data
}
}
}
Use ThreadPoolExecutor for concurrent tasks
ThreadPoolExecutor can help you manage a pool of worker threads for executing tasks concurrently. It Improves performance by parallelizing independent tasks. Example:
import java.util.concurrent.Executors
val threadPoolExecutor = Executors.newFixedThreadPool(4)
fun performTask(task: Runnable) {
threadPoolExecutor.execute(task)
}
Use WorkManager for deferrable, guaranteed tasks
WorkManager is part of Android Jetpack and provides a simple way to schedule background tasks. However it is not suitable for tasks that need to be executed immediately.
Example:
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
class MyWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
override fun doWork(): Result {
// Perform background task
return Result.success()
}
}
val myWorkRequest = OneTimeWorkRequest.Builder(MyWorker::class.java).build()
WorkManager.getInstance(this).enqueue(myWorkRequest)
Storage Optimization
Use appropriate storage options
- Use Shared Preferences for small amounts of data in key-value pairs. It is perfect for storing simple configuration settings or user preferences.
val sharedPreferences = getSharedPreferences("MyAppPrefs", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("username", "JohnDoe")
editor.apply()
- SQLite: A lightweight relational database that allows you to store structured data. It's useful for apps that need to manage a moderate amount of data with complex relationships. See example:
val dbHelper = object : SQLiteOpenHelper(context, "my_database.db", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
}
val db = dbHelper.writableDatabase
val contentValues = ContentValues().apply {
put("id", 1)
put("name", "John Doe")
}
db.insert("users", null, contentValues)
- Room: A higher-level abstraction over SQLite, providing a more robust and type-safe way to manage structured data storage. Room simplifies database operations and integrates with other Android components like LiveData.
// Create entity, DAO, and database classes
// ...
val db = Room.databaseBuilder(applicationContext, AppDatabase::class.java, "my-db").build()
val userDao = db.userDao()
Optimize database queries and transactions
Efficient database queries and transactions are essential for reducing the time your app spends accessing the database, which in turn improves performance.
Some tips for optimizing database operations include:
- Use indexes on frequently queried columns to speed up query execution.
- Limit the number of rows returned by a query using the LIMIT keyword.
- Write transactions to batch multiple insertions, updates, or deletions together, reducing the overhead of individual operations.
- Use parameterized queries to prevent SQL injection attacks and improve performance.
Example of using LIMIT
and parameterized query with Room:
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE age > :minAge LIMIT 10")
fun getUsersAboveAge(minAge: Int): List<User>
}
Perform expensive storage operations asynchronously
Running storage operations asynchronously prevents blocking the main thread, ensuring a smooth user experience. You can use Kotlin Coroutines, AsyncTask, or other multithreading techniques to achieve this
Use Android Jetpack Libraries
Android Jetpack is a collection of libraries that aim to simplify and streamline UI development. They are designed to improve app performance, maintainability, and reduce boilerplate code.
Architecture components: ViewModel, LiveData, and Data Binding
- ViewModel: A class that helps manage and store UI-related data in a lifecycle-conscious way. It allows data to survive configuration changes and keeps UI data separate from the activity or fragment.
- LiveData: An observable data holder class that is lifecycle-aware. It ensures that updates are only sent to active observers, such as an activity or fragment in the started or resumed state. Example with LiveData (combined with ViewModel):
class MainActivity : AppCompatActivity() {
private lateinit var userViewModel: UserViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
userViewModel = ViewModelProvider(this).get(UserViewModel::class.java)
userViewModel.userName
userViewModel.userName.observe(this, { name ->
// Update UI when the userName value changes
textView.text = name
})
- Data Binding: A library that allows you to bind UI components directly to data sources in your XML layouts, reducing the need for boilerplate code in your activities or fragments.
WorkManager for efficient background tasks
WorkManager is a library for managing and scheduling deferrable, asynchronous tasks that must run even if the app exits or the device restarts. It takes care of handling compatibility issues and provides a simplified API for performing background work. However this might introduce some overhead in certain cases.
Android KTX for Kotlin extensions that simplify code
KTX is a set of Kotlin extensions designed to make android dev with Kotlin more concise and idiomatic. It provides extension functions and properties for common tasks, simplifying your code.
ConstraintLayout for more efficient UI design
ConstraintLayout is a flexible layout manager that allows you to create complex UIs with a flat view hierarchy, improving performance. It also provides a powerful design editor in Android Studio, making it easy to create and modify layouts visually.
That’s it for now!
By implementing these strategies, you're well on your way to building a high-performance Android app that users will love. In the upcoming part 2 of this series, we'll delve into the remaining optimization techniques, such as optimizing battery usage, ensuring compatibility across various devices, and keeping app size small, among others.
Stay tuned. Happy coding!