“Do’s” in modern Android/Kotlin Development (Tips & Good Practices) — Chapter I

A Poplawski
10 min readSep 18, 2023

--

Photo by Diego PH on Unsplash

This series of articles will discuss variety of practices, actions and tips that are likely to improve your: code quality, product stability, relationships with fellow devs and happiness of your PM/client. I will refer to them as “Do’s”.

Following “Do’s” I developed throughout my 4 years journey as Android developer. They will concern various different topics; coding best practices, design patterns that might benefit code quality, but also processes and actions, that should improve workflow and maintainability of the project.

While some of the “Do’s” might be an overkill for a small, hobby project, they can really shine in medium and large commercial projects, where complexity can really become an issue and quality is a requirement.

Part 2: https://medium.com/@a.poplawski96/dos-in-modern-android-kotlin-development-tips-good-practices-chapter-ii-81b0c0c56667

I also write on “Don’ts”, bad practices & anti-pattenrs:
Part 1: https://medium.com/@a.poplawski96/donts-in-modern-android-kotlin-development-bad-practices-anti-patterns-chapter-i-d38cba2f5f7d
Part 2: https://medium.com/@a.poplawski96/donts-in-modern-android-kotlin-development-bad-practices-anti-patterns-chapter-ii-984b501aed4b

1. Reduce nesting when code becomes cluttered

Nesting code in Kotlin is often inevitable. Not only due to basic if/else statements or loops, but also:
- Launching coroutine scopes
- Switching dispatchers using withContext()
- when() statements
- Scope functions
- … and others

And it’s absolutely fine.

“A little nesting never killed nobody.” — Fergie

Most of the time, nesting won’t affect code readability in a significant way. However, in complex scenarios, our code can get a bit cluttered. Luckily, there are ways to easily reduce nesting. Let’s see a practical example.

class GetSomeData(private val someDataRepository: SomeDataRepository) {

sealed interface Result {
data class Success(val someData: List<String>?) : Result
object UnknownError : Result
object NetworkError : Result
}

suspend fun invoke() : Result = try {
Result.Success(someDataRepository.getSomeData())
} catch (e: IOException) {
Result.NetworkError
} catch (e: Exception) {
Result.UnknownError
}
}

We have a simple usecase/interactor that gets some data from repository, handles potential exception and parses result to a domain object.

We could use GetSomeData in a View Model just like this.

class SomeViewModel(private val getSomeData: GetSomeData) : ViewModel() {

sealed interface ViewState {
data class DataLoaded(val data: List<String>) : ViewState
object Loading : ViewState
object Error : ViewState
}

private val _viewState = MutableStateFlow<ViewState>(ViewState.Loading)
val viewState = _viewState.asStateFlow()

private var isInitialized = false

fun loadData() {
if (isInitialized.not()) {
viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
result.someData?.let { someData ->
if (someData.isEmpty().not()) {
_viewState.value = ViewState.DataLoaded(result.someData)
isInitialized = true
}
}
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = ViewState.Error
}
}
}
}
}
}

While this code works fine, by using if’s, when, let and launching viewModelScope, we introduced some heavy nesting.

Let’s explore 3 simple techniques that will enable us to reduce it, while keeping the same functionality.

I. Invert if conditions

// If inversion applied
fun loadData() {
if (isInitialized) return

viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
result.someData?.let { someData ->
if (someData.isEmpty().not()) {
_viewState.value = ViewState.DataLoaded(result.someData)
isInitialized = true
}
}
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = ViewState.Error
}
}
}
}

Very simple, but often overlooked. Instead of executing a block of code if a condition is met, we can return early if condition is not met.
We can do it one more time.

// If inversion applied, pt.2
fun loadData() {
if (isInitialized) return

viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
result.someData?.let { someData ->
if (someData.isEmpty()) return@launch

_viewState.value = ViewState.DataLoaded(result.someData)
isInitialized = true
}
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = ViewState.Error
}
}
}
}

And just like that, we got rid of 2 nesting levels. Let’s go further.

II. Scope functions are not always the best fit

Scope functions is great Kotlin feature and I absolutely encourage learning and utilizing them. They can reduce plenty of boilerplate in an elegant way.
However, in certain scenarios, they might contribute to making code less readable, i.e. when used in places with plenty of nesting.
Let’s try to remove it.

// If inversion & scope functions removed 
fun loadDataInv() {
if (isInitialized) return

viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
if (result.someData.isNullOrEmpty()) return@launch

_viewState.value = SomeViewModel.ViewState.DataLoaded(result.someData)
isInitialized = true
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = SomeViewModel.ViewState.Error
}
}
}
}

We simply switched isEmpty() with isNullOrEmpty(), so we can omit let scope function.

Let’s compare the results.

// Before if inversion & scope functions removal
fun loadData() {
if (isInitialized.not()) {
viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
result.someData?.let { someData ->
if (someData.isEmpty().not()) {
_viewState.value = SomeViewModel.ViewState.DataLoaded(result.someData)
isInitialized = true
}
}
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = SomeViewModel.ViewState.Error
}
}
}
}
}

// If inversion & scope functions removed
fun loadDataInv() {
if (isInitialized) return

viewModelScope.launch {
when(val result = getSomeData.invoke()) {
is GetSomeData.Result.Success -> {
if (result.someData.isNullOrEmpty()) return@launch

_viewState.value = SomeViewModel.ViewState.DataLoaded(result.someData)
isInitialized = true
}
GetSomeData.Result.NetworkError, GetSomeData.Result.UnknownError -> {
_viewState.value = SomeViewModel.ViewState.Error
}
}
}
}

Much better now, isn’t it? By just using thesesimple techniques, we’ve been able to reduce nesting by 3 levels.

III. Use functions with expression body

Let’s consider a method with exception handling, for example:

// Block body
suspend fun invoke() : GetSomeData.Result {
return try {
GetSomeData.Result.Success(someDataRepository.getSomeData())
} catch (e: IOException) {
GetSomeData.Result.NetworkError
} catch (e: Exception) {
GetSomeData.Result.UnknownError
}
}

It’s a simple function, but in real life scenario you might face code with multiple levels of nesting.
What we can do here, is to convert the function from block body to expression body.

// Expression body
suspend fun invoke() : GetSomeData.Result = try {
GetSomeData.Result.Success(someDataRepository.getSomeData())
} catch (e: IOException) {
GetSomeData.Result.NetworkError
} catch (e: Exception) {
GetSomeData.Result.UnknownError
}

// Block body
suspend fun invoke() : GetSomeData.Result {
return try {
GetSomeData.Result.Success(someDataRepository.getSomeData())
} catch (e: IOException) {
GetSomeData.Result.NetworkError
} catch (e: Exception) {
GetSomeData.Result.UnknownError
}
}

Note that the same technique can apply to withContext().

Tip: use IDE to do it for you. Press Option + Enter (or Alt + Enter) and select “Convert to expression body”.

2. Keep your ViewModels as lightweight as possible

View models are meant to be used at a screen level. Single screen can display data, transform displayed data, handle different user interactions, handle input, etc. In other words, single screen can hold multiple functionalities. Since view models represent the whole screen, they are often destined to be large and complex.

That being said, it is important to try keeping complexity of our view models as low as possible. Let’s consider the following example:

class HeavyWeightViewModel(
private val simpleRepository: SimpleRepository,
private val userRepository: UserRepository,
private val analyticsLogger: AnalyticsLogger,
) : ViewModel() {

companion object {
private const val ANALYTICS_EVENT_LOAD_DATA = "event_load_data"
private const val ANALYTICS_PARAM_USER_ID = "param_user_id"
private const val ANALYTICS_PARAM_SCREEN_NAME = "screen_name"
private const val ANALYTICS_EVENT_LOAD_DATA_ERROR = "event_load_data_error"
}

sealed interface ViewState {
object Idle : ViewState
object Error : ViewState
data class DataLoaded(val someData: SomeData) : ViewState
}

private val _viewState = MutableStateFlow<ViewState>(ViewState.Idle)
val viewState = _viewState.asStateFlow()

fun loadData() {
try {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = simpleRepository.getSomeData()
withContext(Dispatchers.Main) {
_viewState.value = ViewState.DataLoaded(result)
}
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
}
} catch (e: Exception) {
_viewState.value = ViewState.Error
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA_ERROR,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
}

// Some more functions
// ...
}

For demonstration purposes, it’s a simple class. However in real life scenario and commercial projects, you will often deal with more functions with way more logic.

The best way to unweight view models, is to delegate some of the responsibilities to outer layers.

Usually, Android projects will contain classes from at least two more layers, for example: Repositories (data layer), Interactors/UseCases (domain layer). Let’s utilize them all!

While domain layer is suggested only as optional by official Android architecture recommendations, it’s recommended for medium and large sized projects. Let’s introduce it then for the sake of this example.

I. Delegate responsibilities to outer layers

As said before, the best way to unweight view models, is to delegate some of the responsibilities to outer layers. What can be delegated then?

  • Error handling with try/catch blocks
  • Dispatchers switching

Let’s introduce GetSomeDataUseCase class and delegate those responsibilities.

class GetSomeDataUseCase(
private val simpleRepository: SimpleRepository,
private val ioDispatcher: CoroutineDispatcher,
) {

sealed interface Result {
data class Success(val data: SomeData) : Result
object Error : Result
}

suspend fun execute(): Result = withContext(ioDispatcher) {
return@withContext try {
Result.Success(simpleRepository.getSomeData())
} catch (e: Exception) {
Result.Error
}
}
}

And now, we can replace SimpleRepository with GetSomeDataUseCase in our ViewModel.

class MiddleWeightViewModel(
private val getSomeDataUseCase: GetSomeDataUseCase,
private val userRepository: UserRepository,
private val analyticsLogger: AnalyticsLogger,
) : ViewModel() {

companion object {
private const val ANALYTICS_EVENT_LOAD_DATA = "event_load_data"
private const val ANALYTICS_EVENT_LOAD_DATA_ERROR = "event_load_data_error"
private const val ANALYTICS_PARAM_USER_ID = "param_user_id"
private const val ANALYTICS_PARAM_SCREEN_NAME = "screen_name"
}

sealed interface ViewState {
object Idle : ViewState
object Error : ViewState
data class DataLoaded(val someData: SomeData) : ViewState
}

private val _viewState = MutableStateFlow<ViewState>(ViewState.Idle)
val viewState = _viewState.asStateFlow()

fun loadData() {
viewModelScope.launch {
when(val result = getSomeDataUseCase.execute()) {
is GetSomeDataUseCase.Result.Success -> {
_viewState.value = ViewState.DataLoaded(result.data)
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
is GetSomeDataUseCase.Result.Error -> {
_viewState.value = ViewState.Error
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA_ERROR,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
}
}
}
}

Much better now, since view model doesn’t need to care about handling exceptions and switching dispatchers. View model is now MiddleWeightViewModel. But let’s go one step further.

While we could also delegate analytics tracking to the new use case class that we just created, from my experience, it is often convenient to keep it in the view model. Let’s see how we can make it more readable.

II. Introduce Facade pattern

Facade is a structural design pattern that provides a simplified interface to a library, a framework, or any other complex set of classes.

That’s a general definition. In other words, Facade is about hiding implementation details from client classes.

We can create a Facade for analytics logging.

class ViewModelSpecificAnalyticsLoggerFacade(
private val analyticsLogger: AnalyticsLogger,
private val userRepository: UserRepository,
) {

companion object {
private const val ANALYTICS_SCREEN_NAME = "some_screen"
private const val ANALYTICS_EVENT_LOAD_DATA = "event_load_data"
private const val ANALYTICS_PARAM_USER_ID = "param_user_id"
private const val ANALYTICS_PARAM_SCREEN_NAME = "screen_name"
private const val ANALYTICS_EVENT_LOAD_DATA_ERROR = "event_load_data_error"
}

fun logDataLoadSuccess() {
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to ANALYTICS_SCREEN_NAME,
)
)
}

fun logDataLoadError() {
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA_ERROR,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to ANALYTICS_SCREEN_NAME,
)
)
}
}

Lovely, we were able to hide implementation details and encapsulate constants.

Now, let’s use it in the view model and compare it with the HeavyWeightViewModel that we started with.

// After
class LightWeightViewModel(
private val getSomeDataUseCase: GetSomeDataUseCase,
private val analyticsLoggerFacade: ViewModelSpecificAnalyticsLoggerFacade,
) : ViewModel() {

sealed interface ViewState {
object Idle : ViewState
object Error : ViewState
data class DataLoaded(val someData: SomeData) : ViewState
}

private val _viewState = MutableStateFlow<ViewState>(ViewState.Idle)
val viewState = _viewState.asStateFlow()

fun loadData() {
viewModelScope.launch {
when(val result = getSomeDataUseCase.execute()) {
is GetSomeDataUseCase.Result.Success -> {
_viewState.value = ViewState.DataLoaded(result.data)
analyticsLoggerFacade.logDataLoadSuccess()
}
is GetSomeDataUseCase.Result.Error -> {
_viewState.value = ViewState.Error
analyticsLoggerFacade.logDataLoadError()
}
}
}
}
}

// Before
class HeavyWeightViewModel(
private val simpleRepository: SimpleRepository,
private val userRepository: UserRepository,
private val analyticsLogger: AnalyticsLogger,
) : ViewModel() {

companion object {
private const val ANALYTICS_EVENT_LOAD_DATA = "event_load_data"
private const val ANALYTICS_PARAM_USER_ID = "param_user_id"
private const val ANALYTICS_PARAM_SCREEN_NAME = "screen_name"
private const val ANALYTICS_EVENT_LOAD_DATA_ERROR = "event_load_data_error"
}

sealed interface ViewState {
object Idle : ViewState
object Error : ViewState
data class DataLoaded(val someData: SomeData) : ViewState
}

private val _viewState = MutableStateFlow<ViewState>(ViewState.Idle)
val viewState = _viewState.asStateFlow()

fun loadData() {
try {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = simpleRepository.getSomeData()
withContext(Dispatchers.Main) {
_viewState.value = ViewState.DataLoaded(result)
}
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
}
} catch (e: Exception) {
_viewState.value = ViewState.Error
analyticsLogger.logEvent(
eventName = ANALYTICS_EVENT_LOAD_DATA_ERROR,
parameters = mapOf(
ANALYTICS_PARAM_USER_ID to userRepository.getUserId(),
ANALYTICS_PARAM_SCREEN_NAME to "some_screen",
)
)
}
}
}

So nice and clean now, isn’t it?

Facade is, to my experience, very simple, yet useful and overlooked pattern. If you can reduce complexity of crucial parts of your code, you should really consider using it.

3. Remove dead code

Have your app stopped using the old registration form fragment? Maybe you re-wrote an old screen from XML to Compose? Or maybe there are some UI components lurking around the project that are not used anymore?

Remove unused code, please.

You won’t need it. And it’s not about keeping the project clean and tidy. Not removing dead code may waste some of your fellow devs time.

You might ask “but how?”. There are few possibilities.

But before I’ll list them, we have to clarify: it’s not always obvious if code is actually dead. Even IDE may lie to you (or don’t tell the whole truth).

  • Even if it’s impossible to navigate to certain screen in your app, it might not appear as unused in your IDE. There might be a reference to its fragment in navigation graph, there might be a reference to its view model in a Koin/Dagger/Hilt module.
  • It might also appear as unused, even though it’s used. For example, if you implement CastOptionsProvider and only reference it in manifest, it will appear as unused.

That being said, because of the ambiguity, your fellow developers might do some unnecessary work in code that should already be removed, or because of code that should already be removed.

I. Redundant refactoring

Your team might choose to migrate out of some library, introduce some data model or retract from a sketchy solution. If you keep dead code, you will force yourself or your fellow developers to perform refactoring on dead code, and ultimately waste time.

II. Obfuscated navigation

It’s essential to navigate through your codebase, either with Command + F or finding method usages. If you keep dead code, it will navigate yourself or your fellow developers to, making development, debugging and maintenance harder for no reason, ultimately resulting in a waste of time.

Photo by Junseong Lee on Unsplash

And let’s end it there. We’ll cover more “Do’s” in next chapters. If you find this useful, consider following, thanks!

--

--

A Poplawski

Creating Android apps since 2019 - Android, Android TV & Kotlin Multiplatform. Trying to share useful information in a digestible manner.