Skip to main 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.

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

import androidx.lifecycle.ViewModel

class MainViewModel : ViewModel() {
var counter = 0
}
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

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

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.

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

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

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 <layout> tag

<?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
// 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
<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
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
 <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!
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

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.
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
}
}
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.
    @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)
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
}
<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:

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.

// 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
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()
}
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
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
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

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
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.
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?

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.
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

implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.0”
implementation "androidx.activity:activity-ktx:1.1.0"
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

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!

import android.app.Application

class App : Application() {
val database by lazy { AppRoomDatabase.getDatabase(this) }
val repository by lazy { NameRepository(database.nameDAO()) }
}
<?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

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<>

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()
android:onClick="@{() -> viewmodel.insertName(editTextTextPersonName.getText().toString())}"

ListAdapter

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