Skip to content

09 - Android App Architecture and Jetpack

App Architecture

  • Historically architectural patterns in bigger applications have been a problem in Android ecosystem – no official supported/recommended solutions from Google.
  • Slowly Google has developed bunch of different interconnected libraries to solve this problem. All these libraries together are called Android Jetpack.

Jetpack

  • activity
    • Access composable APIs built on top of Activity.
  • appcompat
    • Allows access to new APIs on older API versions of the platform (many using Material Design).
  • camera
    • Build mobile camera apps.
  • compose
    • Define your UI programmatically with composable functions that describe its shape and data dependencies.
  • databinding
    • Bind UI components in your layouts to data sources in your app using a declarative format.
  • fragment
    • Segment your app into multiple, independent screens that are hosted within an Activity.
  • hilt
    • Extend the functionality of Dagger Hilt to enable dependency injection of certain classes from the androidx libraries.
  • lifecycle
    • Build lifecycle-aware components that can adjust behavior based on the current lifecycle state of an activity or fragment.
  • LiveData
    • Observable data holder, lifecycle aware
  • Material Design Components
    • Modular and customizable Material Design UI components for Android.
  • navigation
    • Build and structure your in-app UI, handle deep links, and navigate between screens.
  • paging
    • Load data in pages, and present it in a RecyclerView.
  • room
    • Create, store, and manage persistent data backed by a SQLite database.
  • test
    • Testing in Android.
  • ViewModel
    • store and manage UI-related data in a lifecycle conscious way
  • work
    • Schedule and execute deferrable, constraint-based background tasks.

Jetpack main libraries

From coding/architecture perspective

  • Room
  • LiveData
  • ViewModel
  • Databinding

Jetpack


Jetpack

ViewModel

  • The ViewModel class is designed to hold and manage UI-related data in a life-cycle conscious way. This allows data to survive configuration changes such as screen rotations.
  • Do not hold reference to Activties, Fragments, Contexts or UI elements. Only exception is Application context – and then better extend AndroidViewModel – this already includes App context.

Jetpack

Activity recreation

On rotation the counter value is lost.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class MainActivity : AppCompatActivity() {

    var counter = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        updateUI()
    }

    fun buttonAddOneOnClick(view: View) {
        counter++
        updateUI()
    }

    private fun updateUI(){
        textViewNumber.text = counter.toString()
    }
}

ViewModel example

1
2
3
4
5
import androidx.lifecycle.ViewModel

class MainViewModel : ViewModel() {
   var counter = 0
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import androidx.lifecycle.ViewModelProvider

class MainActivity : AppCompatActivity() {

    private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        updateUI()
    }

    fun buttonAddOneOnClick(view: View) {
        viewModel.counter++
        updateUI()
    }

    private fun updateUI(){
        textViewNumber.text = viewModel.counter.toString()
    }
}

ViewModel details

Use

1
ViewModelProvider(this).get(MainViewModel::class.java)
to initialize viewmodel for specific activity (referred by this) Provider takes care of viewmodel lifecycle, and creates it only once

App build.gradle needs

1
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0"

Jetpack

LiveData

  • LiveData is an observable data holder class.
  • Unlike a regular observable, LiveData is lifecycle-aware, meaning it respects the lifecycle of other app components, such as activities, fragments, or services.
  • LiveData considers an observer, which is represented by the Observer class, to be in an active state if its lifecycle is in the STARTED or RESUMED state.

LiveData - create

Create an instance of LiveData to hold a certain type of data. Usually done in ViewModel class.

1
2
3
4
5
6
7
8
9
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class MainViewModel : ViewModel() {
   val counter: MutableLiveData<Int> by lazy { MutableLiveData<Int>()}
   init {
       counter.value = 5
   }
}

LiveData info

  • Start observing LiveData objects in Activitys onCreate
  • Generally, LiveData delivers updates only when data changes, and only to active observers.
  • Observers also receive an update when they change from an inactive to an active state.
  • If the observer changes from inactive to active a second time, it only receives an update if the value has changed since the last time it became active.

LiveData in activity

LiveData in activity – observer with update method. Observer is passed to livedata observe method

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class MainActivity : AppCompatActivity() {
    private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val counterObserver = Observer<Int>{ newValue -> textViewNumber.text = newValue.toString() }
        viewModel.counter.observe(this, counterObserver)
    }

    fun buttonAddOneOnClick(view: View) {
        viewModel.counter.value = viewModel.counter.value!! + 1
    }
}

LiveData updates

To update mutable livedata

  • Livedata.setValue() – must be called from main thread
  • Livedata.postValue() – posts a task to main thread, executed somewhere in future. If called multiple times – only last value would be dispatched whem main thread executes.


  • Extend, Transform, Merge, kotlin coroutines, Room

Data Binding

  • The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically.
  • There is also View Binding – basically more involved/manual ’kotlin-android-extensions’. It just replaces classical findViewById. No two-way data binding.


Add in app build.gradle to android section

1
2
3
buildFeatures {
        dataBinding true
}

Convert layout to Data Binding layout

  • right click the root element in layout xml
  • choose context actions

Jetpack

Root element was wrapped inside tag

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <TextView
            android:id="@+id/textViewNumber"
            android:layout_width="wrap_content"

Data Binding - Data

  • <data> tag will contain layout variables
  • Layout variables are used to write layout expressions. Layout expressions are placed in the value of element attributes and they use the @{expression} format
1
2
3
4
// Some examples of complex layout expressions
android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age < 13 ? View.GONE : View.VISIBLE}"
android:transitionName='@{"image_" + id}'

Expressions are documented here

https://developer.android.com/topic/libraries/data-binding/expressions

  • Define variables and use them in UI element attributes
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<data>
    <variable name="counter" type="Integer"/>
</data>

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textViewNumber"
     android:text="@{Integer.toString(counter), default=0}"

Data Binding - activity

  • Change inflation, remove UI calls (including kotlin-android-extensions)
  • Based on Layout_name binding class will be generated
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MainActivity : AppCompatActivity() {
    private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)

        val counterObserver = Observer<Int>{ newValue -> binding.counter = newValue}
        viewModel.counter.observe(this, counterObserver)
    }

Data Binding - events

  • Events can also be handled from data binding
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 <data>
        <variable name="counter" type="Integer"/>
        <variable name="activity" type="ee.taltech.viewmodel01.MainActivity"/>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
    tools:context=".MainActivity">
    <Button
        android:onClick="@{(view) -> activity.buttonAddOneOnClick(view)}"
              android:text="Add 1” />
  • Don’t forget to set value to variable declared in binding!
1
2
3
val binding: ActivityMainBinding =
    DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.activity = this

Data Binding - Observables, Viewmodel

  • Data binding supports observables/livedata and viewmodel internally
  • Specify binding.lifecycleOwner=this and ference viewmodel in activity


Move code from activity to viewModel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MainViewModel : ViewModel() {
    val counter: MutableLiveData<Int> by lazy { MutableLiveData<Int>() }

    init {
        counter.value = 5
    }

    fun updateCounter() {
        counter.value = counter.value!! + 1
    }
}
  • lifecycleOwner and binded viewmodel. Layout bindings.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MainActivity : AppCompatActivity() {
    private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.lifecycleOwner = this
        binding.viewmodel = viewModel
    }
}
1
2
android:text="@{Integer.toString(viewmodel.counter), default=0}”
android:onClick="@{() -> viewmodel.updateCounter()}"

Data Binding - Binding Adapters

  • With the Data Binding Library, almost all UI calls are done in static methods called Binding Adapters.
  • The library provides a huge amount of Binding Adapters.
1
2
3
4
5
6
    @BindingAdapter("android:text")
    public static void setText(TextView view, CharSequence text) {
        // Some checks removed for clarity

        view.setText(text);
    }
  • Create custom binding adapter (add plugin: id 'kotlin-kapt’ in build.gradle)
1
2
3
4
5
6
7
import android.view.View
import androidx.databinding.BindingAdapter

@BindingAdapter("app:hideIfZero")
fun hideIfZero(view: View, number: Int) {
    view.visibility = if (number == 0) View.GONE else View.VISIBLE
}
1
2
3
4
<TextView
    android:id="@+id/textViewNumber"
    android:text="@{Integer.toString(viewmodel.counter), default=0}"
    app:hideIfZero="@{viewmodel.counter}"

Coroutines

The pattern of async and await in other languages is based on coroutines. The suspend keyword is similar to async. However in Kotlin, await() is implicit when calling a suspend function.


Bad code to run on UI thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun blockingUI() {
    for (i in 10 downTo 0) {
        viewModel.counter.value = i
        Thread.sleep(500)
    }
}

fun buttonSleepOnClick(view: View) {
    blockingUI()
}

Callbacks

One pattern for performing long-running tasks without blocking the main thread is callbacks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
    // The slow network request runs on another thread
    slowFetch { result ->
        // When the result is ready, this callback will get the result
        show(result)
    }
    // makeNetworkRequest() exits after calling slowFetch without waiting for the result
}

Coroutines

  • Coroutine based version
    import androidx.lifecycle.lifecycleScope
  • xxxxScope – to tie long running functions into lifecycle events
  • ViewModelScope
  • LifecycleScope
  • LiveData
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
suspend fun nonblockingUI() {
    for (i in 10 downTo 0) {
        viewModel.counter.value = viewModel.counter.value!! - i
        //SystemClock.sleep(500);
        delay(500)
    }
}

fun buttonSleepOnClick(view: View) {
    lifecycleScope.launch {
        nonblockingUI();
    }
    // blockingUI()
}
1
2
3
4
5
val user: LiveData<User> = liveData {
    val data = database.loadUser() 
    // loadUser is a suspend function.
    emit(data)
}

Room 1

  • The Room persistence library provides an abstraction layer over SQLite to allow for more robust database access while harnessing the full power of SQLite.
  • Add dependencies in app build.gradle
    1
    2
    3
    4
    def room_version = "2.2.6"  
    implementation "androidx.room:room-runtime:$room_version"  
    kapt "androidx.room:room-compiler:$room_version"  
    implementation "androidx.room:room-ktx:$room_version”  
    
  • Add to android section
    1
    packagingOptions { exclude 'META-INF/atomicfu.kotlin_module'  }
    

Room 2

Entity, SQLite, DAO, Room Database, Repository, LiveData, ViewModel

Jetpack

  • Entity: Annotated class that describes a database table when working with Room.
  • SQLite database: On device storage. The Room persistence library creates and maintains this database for you.
  • DAO: Data access object. A mapping of SQL queries to functions. When you use a DAO, you call the methods, and Room takes care of the rest.
  • Room database: Simplifies database work and serves as an access point to the underlying SQLite database (hides SQLiteOpenHelper). The Room database uses the DAO to issue queries to the SQLite database.
  • Repository: A class that you create that is primarily used to manage multiple data sources.
  • ViewModel: Acts as a communication center between the Repository (data) and the UI.
  • LiveData: A data holder class that can be observed. LiveData is lifecycle aware. UI components just observe relevant data and don't stop or resume observation.

Room 3

  • Entity – use kotlin Data class
    • https://kotlinlang.org/docs/data-classes.html
  • Use annotations
    • https://developer.android.com/training/data-storage/room/defining-data.html
1
2
3
4
5
6
7
8
9
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "name_table")
data class Name(
    @PrimaryKey(autoGenerate = true) val id: Int,
    @ColumnInfo(name = "name_value") val nameValue: String
)

Room DAO

  • Specifies SQL queries and associate them with method calls. The compiler checks the SQL and generates queries from convenience annotations for common queries, such as @Insert, @Delete, @Update
  • Room DAO must be interface or abstract class
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Dao
interface NameDAO {
    @Query("SELECT * FROM name_table ORDER BY name_value ASC")
    fun getAlphabetizedNames(): List<Name>

    @Insert
    suspend fun insert(name: Name)

    @Query("DELETE FROM name_table")
    suspend fun deleteAll()
}

Room - Observing

Observing database changes

  • A Flow is an async sequence of values
  • Flow produces values one at a time (instead of all at once) that can generate values from async operations like network requests, database calls, or other async code.
1
2
3
4
5
6
import kotlinx.coroutines.flow.Flow

@Dao
interface NameDAO {
    @Query("SELECT * FROM name_table ORDER BY name_value ASC")
    fun getAlphabetizedNames(): Flow<List<Name>>

Room - Database

  • Room database – layer on top of SQLite. Takes care of SQLiteOpenHelper tasks.
  • By default, to avoid poor UI performance, Room doesn't allow you to issue queries on the main thread. When Room queries return Flow, the queries are automatically run asynchronously on a background thread.
  • Room provides compile-time checks of SQLite statements


  • Singleton pattern
  • Abstract class
  • Extend RoomDatabase


  • Migrations?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [Name::class], version = 1, exportSchema = false)
abstract class AppRoomDatabase : RoomDatabase() {
    abstract fun nameDAO(): NameDAO
    companion object {
        @Volatile
        private var INSTANCE: AppRoomDatabase? = null
        fun getDatabase(context: Context): AppRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppRoomDatabase::class.java,
                    "app_database"
                ).fallbackToDestructiveMigration().build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Room - repo

  • Repository - abstracts access to multiple data sources.
  • In the most common example, the Repository implements the logic for deciding whether to fetch data from a network or use results cached in a local database.

Jetpack

  • Uses only DAO, not whole DB
  • All queries are executed on separate thread
  • The suspend modifier tells the compiler that this needs to be called from a coroutine or another suspending function.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import androidx.annotation.WorkerThread
import kotlinx.coroutines.flow.Flow

class NameRepository(private val nameDAO: NameDAO) {
    val allNames: Flow<List<Name>> = nameDAO.getAlphabetizedNames()

    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(name: Name) {
        nameDAO.insert(name)
    }
}

Room - viewmodel

Viewmodel with Room data

1
2
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0”
implementation "androidx.activity:activity-ktx:1.1.0"
1
2
3
4
5
6
7
8
9
class MainViewModel(private val repository: NameRepository) : ViewModel() {

    val allNames: LiveData<List<Name>> = repository.allNames.asLiveData()

    fun insertName(name: String) = viewModelScope.launch {
        repository.insert(Name(0, name))
     }

}

Since now viewmodel has constructor parameter – factory is needed

1
2
3
4
5
6
7
8
9
class MainViewModelFactory(private val repository: NameRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return MainViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Create instances of Db and repository in application. Manifest!

1
2
3
4
5
6
import android.app.Application

class App : Application()  {
    val database by lazy { AppRoomDatabase.getDatabase(this) }
    val repository by lazy { NameRepository(database.nameDAO()) }
}
1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="ee.taltech.viewmodel01">

    <application
        android:name=".App"

Instantiate viewmodel using factory

1
2
3
4
5
6
class MainActivity : AppCompatActivity() {
    // private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    private val viewModel: MainViewModel by viewModels {
        MainViewModelFactory((application as App).repository)
    }

Room - LiveData

Observe LiveData converted from Room Flow<>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels {
        MainViewModelFactory((application as App).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        val adapter = NameListAdapter()
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        viewModel.allNames.observe(this) { names ->
            // Update the cached copy of the words in the adapter.
            names.let { adapter.submitList(it) }
        }
  • EdiText and Button to add name
  • Kotlin style element.text is not supported,
    needs java syntax - .getText()
1
android:onClick="@{() -> viewmodel.insertName(editTextTextPersonName.getText().toString())}"

ListAdapter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class NameListAdapter : ListAdapter<Name, NameListAdapter.NameViewHolder>(NamesComparator())  {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NameViewHolder {
        return NameViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: NameViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.id, current.nameValue)
    }

    class NameViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val idItemView: TextView = itemView.findViewById(R.id.textViewId)
        private val nameItemView: TextView = itemView.findViewById(R.id.textViewName)

        fun bind(id: Int, name: String) {
            idItemView.text = id.toString()
            nameItemView.text = name
        }

        companion object {
            fun create(parent: ViewGroup): NameViewHolder {
                val view: View = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_item, parent, false)
                return NameViewHolder(view)
            }
        }
    }

    class NamesComparator : DiffUtil.ItemCallback<Name>() {
        override fun areItemsTheSame(oldItem: Name, newItem: Name): Boolean {
            return oldItem === newItem
        }
        override fun areContentsTheSame(oldItem: Name, newItem: Name): Boolean {
            return oldItem.nameValue == newItem.nameValue
        }
    }
}

ListAdapter

  • RecyclerView.Adapter base class for presenting List data in a RecyclerView, including computing diffs between Lists on a background thread.
  • Adapter.submitList(your_list) – send data to adapter. Diff will be computed and changes displayed
  • DiffUtil is a utility class that can calculate the difference between two lists and output a list of update operations that converts the first list into the second one. It can be used to calculate updates for a RecyclerView Adapter