Skip to main content
· 11 min read

11 Ways To Optimize App Performance Part 1

BlogPostImage

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.

Android Profiler

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.

Layout inspector

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!

Authors
Yash Khandelwal
Share

Dashwave is now live in public beta

Make builds faster using remote caching and collaborate with shareable emulators

Related Posts