$30 off During Our Annual Pro Sale. View Details »

Getting ready for Declarative UIs with Unidirectional Data Flow using Kotlin Coroutines

Getting ready for Declarative UIs with Unidirectional Data Flow using Kotlin Coroutines

Unidirectional Data Flow (UDF) is a powerful technique that enhances our Reactive apps to work deterministically. Synchronising our views with fresh data was never an easy task to accomplish. For this same reason, there are mechanisms that support us to make that possible. Surely callbacks were a thing in the past, however, they were an anti-pattern themselves due to the lack of readability. Now we don't need to deal with them any more thanks to Kotlin Coroutines. Getting ready for Declarative UIs with Kotlin Coroutines and friends is indeed feasible, now we could use suspend functions, Flow and in the end StateFlow would make our Reactive apps ready for Declarative UIs. Let’s define a single entry point, receive data, transform it into a state, and render each state. Let’s get our apps ready for a Declarative UI world on Android.

Key takeaways:
You'll learn how to use Kotlin Coroutines and friends from the Kotlin Coroutines library to take advantage of really efficient and easy to read code. How to handle its lifecycle without being compromised to a specific external Android framework, which would enable your code to be prepared for more purposes than Android only apps.

Conferences or meetups:
- FOSDEM conf (February the 7th 2021)
- Virtual Kotlin (KUG) April meetup (April the 29th 2021)
- RockNDroid Vigo May meetup (May the 12th 2021)
- Kotlin London (KUG) June meetup (June the 2nd 2021)
- DevDays Europe conf (June the 8th 2021)
- Brighton Kotlin (June the 24th 2021)
- Kotlin Stuttgart (KUG) Vol. 9 (July the 14th 2021)

Raul Hernandez Lopez

February 07, 2021
Tweet

More Decks by Raul Hernandez Lopez

Other Decks in Programming

Transcript

  1. Getting ready for
    Declarative UIs
    with Unidirectional Data Flow
    using Kotlin Coroutines
    @raulhernandezl

    View Slide

  2. Software Engineer Raul Hernandez Lopez
    @ Twitter raulh82vlc
    @raulhernandezl

    View Slide

  3. Agenda
    @raulhernandezl

    View Slide

  4. 1. Use case
    2. Analyse an existing architecture
    3. Adopting Unidirectional Data Flow
    4. Implementation
    5. Lessons learned
    6. Why Compose?
    7. Next steps
    @raulhernandezl
    AGENDA

    View Slide

  5. Use case
    @raulhernandezl

    View Slide

  6. Tweets Search
    sample app
    @raulhernandezl
    ● Loading spinner
    ● Empty results text
    ● List of results
    ● Error message text

    View Slide

  7. Analyse an existing
    architecture
    @raulhernandezl

    View Slide

  8. Start adopting on an existing
    Architecture
    @raulhernandezl

    View Slide

  9. Repository
    Network data
    source
    DB data source
    Data Layer
    @raulhernandezl
    results
    results
    results
    ● Repository manages
    data sources

    View Slide

  10. Use Case Repository
    Business Layer
    @raulhernandezl
    requests
    results
    ● Use Case
    performs any
    business logic
    and returns
    values
    callback
    (results)
    transforms

    View Slide

  11. Presenter Use Case
    Model View Presenter (MVP) + Clean Architecture
    @raulhernandezl
    requests
    executes
    callback
    (results)
    results
    callback
    (results)
    ● Presenter connects
    business logic with views

    View Slide

  12. Presenter
    View
    View connection with Presenter
    @raulhernandezl
    executes
    callback
    (results)
    callback
    (results)
    ● View receives new data

    View Slide

  13. View
    User intention
    @raulhernandezl
    ● The user starts typing a
    query of interest
    types a new
    query

    View Slide

  14. Presenter
    View
    System interaction
    @raulhernandezl
    View Delegate
    View
    Listener
    starts query
    injects
    types a new
    query
    callback
    (results)
    ● Starting a search query
    ● React to different queries

    View Slide

  15. Analysing Coroutines
    @raulhernandezl

    View Slide

  16. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    DATA SOURCES: SUSPEND
    @raulhernandezl
    results
    results

    View Slide

  17. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    View Delegate
    requests
    View
    Listener
    REPOSITORY: FLOW / SUSPEND
    @raulhernandezl
    Flow
    results
    results
    results
    DB data source

    View Slide

  18. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    View Delegate
    executes requests
    View
    Listener
    USE CASE: FLOW
    @raulhernandezl
    Flow
    results results
    transforms

    View Slide

  19. How to get ready for Declarative UIs?
    @raulhernandezl

    View Slide

  20. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    CALLBACKS or NOT?
    @raulhernandezl
    results
    results
    results
    callback
    (results)
    callback
    (results)

    View Slide

  21. Any disadvantages on Callbacks?
    @raulhernandezl

    View Slide

  22. Lack of readability = Callback hell
    @raulhernandezl
    Callbacks into callbacks

    View Slide

  23. Callbacks break Referential Transparency
    principle
    @raulhernandezl

    View Slide

  24. Referential Transparency (RT) def.
    The ability to make larger functions out of
    smaller ones through composition and
    produce always the same output for a given
    input.
    @raulhernandezl

    View Slide

  25. Consequences of RT broken?
    @raulhernandezl

    View Slide

  26. Lack of a return value.
    @raulhernandezl
    Consequences of RT broken?

    View Slide

  27. Lack of a return value.
    Results are declared as input parameters.
    @raulhernandezl
    Consequences of RT broken?

    View Slide

  28. Results can mutate.
    @raulhernandezl

    View Slide

  29. Lack of Determinism
    Results can mutate.
    @raulhernandezl

    View Slide

  30. Are there better alternatives to Callbacks?
    @raulhernandezl

    View Slide

  31. StateFlow
    Always returns last value when different.
    @raulhernandezl

    View Slide

  32. StateFlow
    Results are immutable.
    @raulhernandezl

    View Slide

  33. StateFlow
    is Deterministic by default.
    @raulhernandezl

    View Slide

  34. Presenter Use Case Repository
    View
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    CALLBACKS -> STATEFLOW
    @raulhernandezl
    Flow
    results
    StateFlow StateFlow results
    results
    StateFlow

    View Slide

  35. Presenter Use Case Repository
    View
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    COLLECT STATEFLOW
    @raulhernandezl
    Flow
    Flow
    StateFlow
    StateFlow
    Handler
    results
    StateFlow results
    results
    StateFlow

    View Slide

  36. Adopting the
    Unidirectional Data
    Flow (UDF)
    @raulhernandezl

    View Slide

  37. One-way data flow
    @raulhernandezl

    View Slide

  38. @raulhernandezl
    UI State
    Action
    transforms
    results
    renders
    intends

    View Slide

  39. @raulhernandezl
    UseCase
    triggers
    action
    Presenter
    executes
    action
    Imperative UI
    intends
    action
    View Delegate
    ACTION

    View Slide

  40. @raulhernandezl
    Imperative UI StateFlowHandler
    UseCase
    communicates
    state
    dispatches
    state
    Imperative UI View Delegate
    renders
    Presenter
    STATE

    View Slide

  41. @raulhernandezl
    Imperative UI StateFlowHandler
    UseCase
    communicates
    state
    dispatches
    state
    Imperative UI View Delegate
    renders
    Presenter
    STATE

    View Slide

  42. Imperative & Declarative UIs can co-exist
    @raulhernandezl

    View Slide

  43. @raulhernandezl
    Imperative
    Declarative UI
    StateFlowHandler
    UseCase
    communicates
    state
    dispatches
    state
    Imperative UI View Delegate
    renders
    Presenter
    STATE

    View Slide

  44. Advantages
    @raulhernandezl

    View Slide

  45. Flexibility
    @raulhernandezl

    View Slide

  46. Layers composition
    @raulhernandezl

    View Slide

  47. Implementation
    @raulhernandezl

    View Slide

  48. Data Sources:
    Network & DB
    @raulhernandezl

    View Slide

  49. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    DATA SOURCES: SUSPEND
    @raulhernandezl
    results
    results

    View Slide

  50. @Singleton
    class NetworkDataSourceImpl @Inject constructor(
    private val twitterApi: TwitterApi,
    private val connectionHandler: ConnectionHandler,
    private val requestsIOHandler: RequestsIOHandler
    ) : NetworkDataSource
    NetworkDataSourceImpl constructor dependencies
    @raulhernandezl

    View Slide

  51. suspend fun search(token: String, query: String)
    : Either>
    NetworkDataSource w/ suspend
    @raulhernandezl

    View Slide

  52. @Query("SELECT *
    FROM ${Tweet.TABLE_NAME}
    WHERE tweet_id IN(:tweetIds) ORDER BY created_at DESC")
    suspend fun retrieveAllTweetsForTweetsIds(tweetIds: List):
    List
    TweetDao: Database datasource (DAO) w/
    suspend
    @raulhernandezl

    View Slide

  53. Repository:
    suspend & Flow
    @raulhernandezl

    View Slide

  54. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    View Delegate
    requests
    View
    Listener
    REPOSITORY: FLOW / SUSPEND
    @raulhernandezl
    Flow
    results
    results
    results
    DB data source

    View Slide

  55. @Singleton
    class TweetsRepositoryImpl @Inject constructor(
    private val networkDataSource: NetworkDataSource,
    private val tweetsDataSource: TweetDao,
    private val mapperTweets: TweetsNetworkToDBMapperList,
    private val tokenDataSource: TokenDao,
    private val queryDataSource: QueryDao,
    private val tweetQueryJoinDataSource: TweetQueryJoinDao,
    private val mapperToken: TokenNetworkToDBMapper,
    private val taskThreading: TaskThreading
    ) : TweetsRepository {
    TweetsRepository constructor dependencies
    @raulhernandezl

    View Slide

  56. @Query("SELECT *
    FROM ${Tweet.TABLE_NAME}
    WHERE tweet_id IN(:tweetIds) ORDER BY created_at DESC")
    fun retrieveAllTweetsForTweetsIdsFlow(tweetIds: List):
    Flow>
    Database datasource (DAO) w/ Flow
    @raulhernandezl

    View Slide

  57. suspend fun getSearchTweets(query: String): List {
    ...
    val tweetIds = tweetQueryJoinDataSource.retrieveAllTweetsIdAQuery(query)
    return tweetsDataSource.retrieveAllTweetsForTweetsIdsFlow(tweetIds)
    }
    @raulhernandezl
    Case 1) TweetsRepository: Can Flow be returned into a
    suspend function?
    ???

    View Slide

  58. suspend fun getSearchTweets(query: String): List {
    ...
    val tweetIds = tweetQueryJoinDataSource.retrieveAllTweetsIdAQuery(query)
    return tweetsDataSource.retrieveAllTweetsForTweetsIdsFlow(tweetIds)
    }
    Case 1) TweetsRepository: Flow cannot be returned into
    suspend function!
    @raulhernandezl
    NO!

    View Slide

  59. suspend fun getSearchTweets(query: String): List {
    ...
    return tweetsDataSource.retrieveAllTweetsForTweetsIds(tweetIds)
    }
    Case 1) TweetsRepository: a suspend function is needed
    @raulhernandezl
    YES!

    View Slide

  60. @Query("SELECT *
    FROM ${Tweet.TABLE_NAME}
    WHERE tweet_id IN(:tweetIds) ORDER BY created_at DESC")
    suspend fun retrieveAllTweetsForTweetsIds(tweetIds: List):
    List
    Database datasource (DAO) with suspend
    @raulhernandezl

    View Slide

  61. Case 2) TweetsRepository: Flow
    @raulhernandezl

    View Slide

  62. fun getSearchTweets(query: String): Flow>
    = flow {
    ...
    emitAll(tweetsDataSource.retrieveAllTweetsForTweetsIdsFlow(tweetIds))
    }.flowOn(taskThreading.ioDispatcher())
    Case 2) TweetsRepository: flow builder & emitAll values
    from DB (Flow)
    @raulhernandezl
    YES!

    View Slide

  63. fun getSearchTweets(query: String): Flow>
    = flow {
    ...
    emit(tweetsDataSource.retrieveAllTweetsForTweetsIds(tweetIds))
    }.flowOn(taskThreading.ioDispatcher())
    Case 2) TweetsRepository: flow builder & emit a suspend
    fun returning a List
    @raulhernandezl
    YES!

    View Slide

  64. fun getSearchTweets(query: String): Flow>
    = flow {
    ...
    // retrieve old values from DB
    emit(tweetsDataSource.retrieveAllTweetsForTweetsIds(tweetIds))
    // get fresh values from network & saved them into DB
    ...
    // saved network into DB & emit fresh values from DB
    emit(tweetsDataSource.retrieveAllTweetsForTweetsIds(tweetIds))
    }.flowOn(taskThreading.ioDispatcher())
    Case 2) TweetsRepository: flow builder & emit more than
    once
    @raulhernandezl
    YES!

    View Slide

  65. Use Case Overview
    @raulhernandezl

    View Slide

  66. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    View Delegate
    executes requests
    View
    Listener
    USE CASE: FLOW
    @raulhernandezl
    Flow
    results results
    transforms

    View Slide

  67. Presenter Use Case Repository
    View /
    Callbacks
    Network data
    source
    DB data source
    WHERE ARE CALLBACKS?
    @raulhernandezl
    results
    results
    results
    callback
    (results)
    callback
    (results)
    transforms

    View Slide

  68. @RetainedScope
    class SearchTweetUseCase @Inject constructor(
    private val tweetsRepository: TweetsRepository,
    @Named("CoroutineRetainedScope") private val scope: CoroutineScope
    ) : UseCase {
    SearchTweetUseCase: constructor dependencies
    @raulhernandezl

    View Slide

  69. @RetainedScope
    class SearchTweetUseCase @Inject constructor(
    private val tweetsRepository: TweetsRepository,
    @Named("CoroutineRetainedScope") private val scope: CoroutineScope
    ) : UseCase {
    SearchTweetUseCase: constructor dependencies
    @raulhernandezl
    private val scope = CoroutineScope(
    taskThreading.uiDispatcher() +
    SupervisorJob())

    View Slide

  70. AVOID SIDE-EFFECTS ON CANCELLATION
    SCOPE + SupervisorJob
    @raulhernandezl

    View Slide

  71. AVOID SIDE-EFFECTS ON CANCELLATION
    SCOPE + SupervisorJob
    AVOID OTHER CHILDREN
    CANCELLATION
    @raulhernandezl

    View Slide

  72. SCOPE
    +
    SupervisorJob
    JOB 3
    JOB 2
    STRUCTURED CONCURRENCY CANCELLATION
    JOB 1
    @raulhernandezl

    View Slide

  73. Use Case Implementation
    @raulhernandezl

    View Slide

  74. UseCase contract w/ callback
    interface UseCase {
    fun execute(param: String, callbackInput: T?)
    fun cancel()
    }
    @raulhernandezl

    View Slide

  75. override fun execute(query: String,
    callbackInput: SearchCallback?) {
    callback = callbackInput
    callback?.onShowLoader()
    ...
    }
    SearchTweetUseCase: Kotlin
    @raulhernandezl

    View Slide

  76. override fun execute(query: String,
    callbackInput: SearchCallback?) {
    callback = callbackInput
    callback?.onShowLoader()
    scope.launch {
    ...
    }
    }
    SearchTweetUseCase: Kotlin Flow w/ scope.launch
    @raulhernandezl

    View Slide

  77. override fun execute(query: String,
    callbackInput: SearchCallback?) {
    callback = callbackInput
    callback?.onShowLoader()
    scope.launch {
    repository.searchTweet(query)
    .map { // some computation }
    .flowOn(taskThreading.computationDispatcher())
    ...
    }
    }
    SearchTweetUseCase: Kotlin Flow map in another thread
    w/ flowOn upstream
    @raulhernandezl

    View Slide

  78. override fun execute(query: String,
    callbackInput: SearchCallback?) {
    callback = callbackInput
    callback?.onShowLoader()
    scope.launch {
    repository.searchTweet(query)
    .map { // some computation }
    .flowOn(taskThreading.computationDispatcher())
    .catch {
    callback::onError
    }.collect { tweets ->// UI actions for each stream
    callback.onSuccess(tweets)
    }
    }
    }
    SearchTweetUseCase: Flow + Kotlin using collect
    @raulhernandezl

    View Slide

  79. override fun execute(query: String,
    callbackInput: SearchCallback?) {
    callback = callbackInput
    callback?.onShowLoader()
    repository.searchTweet(query)
    .map { // some computation }
    .flowOn(taskThreading.computationDispatcher())
    .onEach { tweets -> // UI actions for each stream
    callback.onSuccess(tweets)
    }.catch {
    callback::onError
    }.launchIn(scope)
    }
    SearchTweetUseCase: Flow + Kotlin using launchIn
    @raulhernandezl

    View Slide

  80. override fun cancel() {
    callback = null
    scope.cancel()
    }
    SearchTweetUseCase: cancellation with Structured
    concurrency
    @raulhernandezl
    private val scope = CoroutineScope(
    taskThreading.uiDispatcher() +
    SupervisorJob())

    View Slide

  81. Synchronous communication
    with the UI
    @raulhernandezl

    View Slide

  82. Presenter Use Case Repository
    View
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    CALLBACKS -> STATEFLOW
    @raulhernandezl
    Flow
    results
    StateFlow StateFlow results
    results
    StateFlow

    View Slide

  83. @RetainedScope
    class SearchTweetUseCase @Inject constructor(
    private val tweetsRepository: TweetsRepository,
    @Named("CoroutineRetainedScope") private val scope: CoroutineScope
    ) : UseCase {
    ) : UseCaseFlow> {
    SearchTweetUseCase recap: constructor dependencies
    @raulhernandezl

    View Slide

  84. SearchTweetUseCase update: remove callback
    @raulhernandezl
    interface UseCaseFlow {
    fun execute(param: String, callbackInput: SearchCallback?)
    fun cancel()
    fun getStateFlow(): StateFlow
    }

    View Slide

  85. SearchTweetUseCase update: remove callback
    interface UseCaseFlow {
    fun execute(param: String, callbackInput: SearchCallback?)
    fun cancel()
    fun getStateFlow(): StateFlow
    }
    @raulhernandezl
    StateFlow can be passed across Java & Kotlin files

    View Slide

  86. @RetainedScope
    class SearchTweetUseCase @Inject constructor(
    private val tweetsRepository: TweetsRepository,
    @Named("CoroutineRetainedScope") private val scope: CoroutineScope
    ) : UseCaseFlow {
    private val tweetsUIStateFlow =
    MutableStateFlow(TweetsUIState.IdleUIState)
    override fun getStateFlow(): StateFlow = tweetsUIStateFlow
    SearchTweetUseCase w/ MutableStateFlow
    @raulhernandezl

    View Slide

  87. @RetainedScope
    class SearchTweetUseCase @Inject constructor(
    private val tweetsRepository: TweetsRepository,
    @Named("CoroutineRetainedScope") private val scope: CoroutineScope
    ) : UseCaseFlow {
    private val tweetsUIStateFlow =
    MutableStateFlow(TweetsUIState.IdleUIState)
    override fun getStateFlow(): StateFlow = tweetsUIStateFlow
    SearchTweetUseCase w/ MutableStateFlow
    distinctUntilChanged by default
    @raulhernandezl
    StateFlow uses distinctUntilChanged by default

    View Slide

  88. /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    }
    TweetsUIState w/ Results state
    @raulhernandezl

    View Slide

  89. TweetsUIState w/ Results state
    @raulhernandezl
    /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    }
    Recomposition

    View Slide

  90. TweetsUIState w/ Error state
    @raulhernandezl
    /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    data class ErrorUIState(val msg: String): TweetsUIState()
    }

    View Slide

  91. TweetsUIState w/ Empty state
    @raulhernandezl
    /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    data class ErrorUIState(val msg: String): TweetsUIState()
    data class EmptyUIState(val query: String): TweetsUIState()
    }

    View Slide

  92. TweetsUIState w/ Loading state
    @raulhernandezl
    /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    data class ErrorUIState(val msg: String): TweetsUIState()
    data class EmptyUIState(val query: String): TweetsUIState()
    object LoadingUIState: TweetsUIState()
    }

    View Slide

  93. TweetsUIState w/ Idle state
    @raulhernandezl
    /**
    * UI States defined for StateFlow in the workflow
    */
    sealed class TweetsUIState {
    data class ListResultsUIState(val tweets: List): TweetsUIState()
    data class ErrorUIState(val msg: String): TweetsUIState()
    data class EmptyUIState(val query: String): TweetsUIState()
    object LoadingUIState: TweetsUIState()
    object IdleUIState: TweetsUIState()
    }

    View Slide

  94. override fun execute(query: String) {
    callback?.onShowLoader()
    repository.searchTweet(query)
    .onStart {
    tweetsStateFlow.value = TweetsUIState.LoadingUIState
    }
    SearchTweetUseCase: Loading state propagation
    @raulhernandezl

    View Slide

  95. override fun execute(query: String) {
    repository.searchTweet(query)
    .onStart {
    tweetsStateFlow.value = TweetsUIState.LoadingUIState
    }.onEach { tweets ->
    val stateFlow =
    if (tweets.isEmpty()) {
    TweetsUIState.EmptyUIState
    }
    tweetsStateFlow.value = stateFlow
    }
    .catch { e ->
    ...
    }.launchIn(scope)
    }
    SearchTweetUseCase: Empty state propagation
    @raulhernandezl

    View Slide

  96. override fun execute(query: String) {
    repository.searchTweet(query)
    .onStart {
    tweetsStateFlow.value = TweetsUIState.LoadingUIState
    }.onEach { tweets ->
    val stateFlow =
    if (tweets.isEmpty()) {
    TweetsUIState.EmptyUIState
    } else {
    TweetsUIState.ListResultsUIState(tweets)
    }
    tweetsStateFlow.value = stateFlow
    }
    .catch { e ->
    ...
    }.launchIn(scope)
    }
    SearchTweetUseCase: List results state propagation
    @raulhernandezl

    View Slide

  97. override fun execute(query: String) {
    repository.searchTweet(query)
    .onStart {
    tweetsStateFlow.value = TweetsUIState.LoadingUIState
    }.onEach { tweets ->
    val stateFlow =
    if (tweets.isEmpty()) {
    TweetsUIState.EmptyUIState
    } else {
    TweetsUIState.ListResultsUIState(tweets)
    }
    tweetsStateFlow.value = stateFlow
    }
    .catch { e ->
    tweetsStateFlow.value =
    TweetsUIState.ErrorUIState(e.msg))
    }.launchIn(scope)
    SearchTweetUseCase: Error state propagation
    @raulhernandezl

    View Slide

  98. Presenter Use Case Repository
    View
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    COLLECT STATEFLOW
    @raulhernandezl
    Flow
    Flow
    StateFlow
    StateFlow
    Handler
    results
    StateFlow results
    results
    StateFlow

    View Slide

  99. SearchTweetPresenter (Java): constructor
    @raulhernandezl
    @ActivityScope
    public class SearchTweetPresenter {
    @NotNull
    private final SearchTweetUseCase tweetSearchUseCase;
    @Inject
    public SearchTweetPresenter(
    @NotNull SearchTweetUseCase tweetSearchUseCase
    ) {
    this.tweetSearchUseCase = tweetSearchUseCase;
    }
    public void cancel() {
    tweetSearchUseCase.cancel();
    }

    View Slide

  100. SearchTweetPresenter (Java) responsibilities
    @raulhernandezl
    @ActivityScope
    public class SearchTweetPresenter {
    ...
    public void searchTweets(@NotNull final String query) {
    if (callback == null && view != null) {
    callback = new SearchCallbackImpl(view);
    }
    tweetSearchUseCase.execute(query, callback);
    }
    @NotNull
    public StateFlow getStateFlow() {
    return tweetSearchUseCase.getStateFlow();
    }
    StateFlow can be passed across Java & Kotlin files

    View Slide

  101. SearchViewDelegate gets StateFlow from Presenter
    @raulhernandezl
    @ActivityScope
    class SearchViewDelegate @Inject constructor(
    private val presenter: SearchTweetPresenter
    @Named("CoroutineUIScope") private val scope: CoroutineScope
    ) {
    fun getStateFlow(): StateFlow = presenter.stateFlow

    View Slide

  102. TweetsListUIFragment (Java) delegates to
    SearchStateHandler
    @raulhernandezl
    public class TweetsListFragmentUI extends BaseFragment {
    ...
    @Inject
    SearchViewDelegate viewDelegate;
    @Inject
    SearchStateHandler stateHandler;
    public void initStateFlowAndViews() {
    stateHandler.initStateFlowAndViews(viewDelegate.getStateFlow(), this);
    stateHandler.processStateFlowCollection();
    }
    StateFlow can be passed across Java & Kotlin files

    View Slide

  103. TweetsListUIFragment w/ StateFlow
    public class TweetsListFragmentUI extends BaseFragment {
    // ...
    @Override
    public View onCreateView(
    @NotNull LayoutInflater inflater,
    @Nullable ViewGroup container,
    @Nullable Bundle savedInstanceState
    ) {
    final View view = inflater.inflate(R.layout.fragment_search,
    container, false);
    initStateFlowAndViews()
    return view;
    }
    @raulhernandezl

    View Slide

  104. StateFlowHandler: constructor
    @raulhernandezl
    @ActivityScope
    class SearchStateHandler @Inject constructor(
    @Named("CoroutineUIScope") private val scope: CoroutineScope
    ) {
    private var tweetsListUI: TweetsListUIFragment? = null
    private lateinit var stateFlow: StateFlow
    fun initStateFlowAndViews(
    val stateFlow: StateFlow,
    val tweetsListUI: TweetsListUIFragment
    ) {
    this.stateFlow = stateFlow
    this.tweetsListUI = tweetsListUI
    }
    ...
    }

    View Slide

  105. SearchStateHandler collects StateFlow w/ onEach
    private lateinit var stateFlow: StateFlow
    fun processStateCollection() {
    stateFlow
    .onEach { uiState ->
    tweetsListUI?.handleStates(uiState)
    }...
    }
    @raulhernandezl

    View Slide

  106. StateFlowHandler collection on scope for Imperative UI
    fun processStateCollection() {
    stateFlow
    .onEach { uiState ->
    tweetsListUI?.handleStates(uiState)
    }.launchIn(scope)
    }
    @raulhernandezl

    View Slide

  107. Imperative (Stateful) UI
    Rendering
    @raulhernandezl

    View Slide

  108. @raulhernandezl
    Imperative UI StateFlowHandler
    UseCase
    communicates
    state
    dispatches
    state
    Imperative UI View Delegate
    triggers
    action
    renders
    intends
    action
    Presenter
    executes
    action
    STATE

    View Slide

  109. TweetsListUIFragment handles State to show Tweets
    @raulhernandezl
    fun TweetsListFragmentUI.handleStates(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState -> showResults(uiState.tweets)
    }
    }

    View Slide

  110. TweetsListUIFragment handles Stateful Loader
    @raulhernandezl
    fun TweetsListFragmentUI.showResults(
    tweets: List
    ) {
    hideLoader()
    hideError()
    showList()
    updateList(tweets)
    }
    Loading
    data... Loading data...

    View Slide

  111. TweetsListUIFragment handles Stateful Error
    @raulhernandezl
    fun TweetsListFragmentUI.showResults(
    tweets: List
    ) {
    hideLoader()
    hideError()
    showList()
    updateList(tweets)
    }
    There is an
    error for... Loading data...

    View Slide

  112. TweetsListUIFragment handles Stateful RecyclerView
    @raulhernandezl
    fun TweetsListFragmentUI.showResults(
    tweets: List
    ) {
    hideLoader()
    hideError()
    showList()
    updateList(tweets)
    }
    RecyclerView
    LayoutManager
    Item Decorator
    RecyclerView

    View Slide

  113. TweetsListUIFragment handles Stateful RecyclerView
    @raulhernandezl
    fun TweetsListFragmentUI.showResults(
    tweets: List
    ) {
    hideLoader()
    hideError()
    showList()
    updateList(tweets)
    }
    SearchStateHandler
    Adapter
    ViewHolder
    tweets
    action
    StateFlow
    TweetsUIState.ListUIState

    View Slide

  114. TweetsListUIFragment to show text states
    @raulhernandezl
    fun TweetsListFragmentUI.handleStates(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState -> showResults(uiState.tweets)
    is TweetsUIState.LoadingUIState -> showLoading()
    is TweetsUIState.EmptyUIState -> showEmptyState(uiState.query)
    is TweetsUIState.ErrorUIState -> showError(uiState.msg)
    }
    }

    View Slide

  115. TweetsListUIFragment handles to do nothing on Idle
    @raulhernandezl
    fun TweetsListFragmentUI.handleStates(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState -> showResults(uiState.tweets)
    is TweetsUIState.LoadingUIState -> showLoading()
    is TweetsUIState.EmptyUIState -> showEmptyState(uiState.query)
    is TweetsUIState.ErrorUIState -> showError(uiState.msg)
    is TweetsUIState.IdleUIState -> {}
    }
    }

    View Slide

  116. Declarative (Stateless) UI
    migration
    @raulhernandezl

    View Slide

  117. @raulhernandezl
    Imperative
    Declarative UI
    StateFlowHandler
    UseCase
    dispatches
    collected
    state
    dispatches
    state
    Imperative UI View Delegate
    triggers
    action
    renders
    intends
    action
    Presenter
    executes
    action
    STATE

    View Slide

  118. TweetsListUIFragment: Compose Interoperability
    public class TweetsListFragmentUI extends BaseFragment {
    @Inject
    SearchComposablesUI searchComposablesUI;
    @Inject
    SearchViewDelegate viewDelegate;
    @Inject
    SearchStateHandler stateHandler;
    ...
    }
    @raulhernandezl

    View Slide

  119. XML layout Interoperability
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    android:id="@+id/compose_view"
    android:layout_marginTop="?attr/actionBarSize"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
    @raulhernandezl

    View Slide

  120. ComposeView Interoperability
    public class TweetsListUIFragment extends BaseFragment {
    @Override
    public View onCreateView(
    @NotNull LayoutInflater inflater,
    @Nullable ViewGroup container,
    @Nullable Bundle savedInstanceState
    ) {
    final View view = inflater.inflate(R.layout.fragment_search,
    container, false);
    final ComposeView composeView = view.findViewById(R.id.compose_view);
    // ...
    return view;
    }
    @raulhernandezl

    View Slide

  121. TweetsListUIFragment ComposeView w/ StateFlow
    @Override
    public View onCreateView(
    @NotNull LayoutInflater inflater,
    @Nullable ViewGroup container,
    @Nullable Bundle savedInstanceState
    ) {
    final View view = inflater.inflate(R.layout.fragment_search,
    container, false);
    final ComposeView composeView = view.findViewById(R.id.compose_view);
    searchComposablesUI.setComposeView(composeView);
    ...
    return view;
    }
    @raulhernandezl

    View Slide

  122. TweetsListUIFragment ComposeView w/ StateFlow
    @Override
    public View onCreateView(
    @NotNull LayoutInflater inflater,
    @Nullable ViewGroup container,
    @Nullable Bundle savedInstanceState
    ) {
    final View view = inflater.inflate(R.layout.fragment_search,
    container, false);
    final ComposeView composeView = view.findViewById(R.id.compose_view);
    searchComposablesUI.setComposeView(composeView);
    return view;
    }
    @raulhernandezl
    fun setComposeView(
    composeView: ComposeView
    ) {
    this.composeView = composeView
    }

    View Slide

  123. TweetsListUIFragment delegates handling to
    SearchStateHandler
    @raulhernandezl
    public class TweetsListUIFragment extends BaseFragment {
    ...
    public void initStateFlowAndViews() {
    stateHandler.initStateFlowAndViews(viewDelegate.getStateFlow(),
    searchComposablesUI);
    stateHandler.processStateFlowCollection();
    }

    View Slide

  124. SearchStateHandler collaborates with SearchComposablesUI
    @raulhernandezl
    @ActivityScope
    class SearchStateHandler @Inject constructor(
    @Named("CoroutineUIScope") private val scope: CoroutineScope
    ) {
    private var searchComposablesUI: SearchComposablesUI? = null
    private lateinit var stateFlow: StateFlow
    fun initStateFlowAndViews(
    val stateFlow: StateFlow,
    val searchComposablesUI: SearchComposablesUI
    ) {
    this.stateFlow = stateFlow
    this.searchComposablesUI = searchComposablesUI
    }
    ...
    }

    View Slide

  125. TweetsListUIFragment delegates to SearchStateHandler
    @raulhernandezl
    public class TweetsListUIFragment extends BaseFragment {
    ...
    private void initStateFlowAndViews() {
    stateHandler.initStateFlowAndViews(
    viewDelegate.getStateFlow(),searchComposablesUI);
    stateHandler.processStateFlowCollection();
    }

    View Slide

  126. SearchStateHandler starts SearchComposablesUI
    class SearchStateHandler @Inject constructor(
    @Named("CoroutineUIScope") private val scope: CoroutineScope
    ) {
    fun processStateCollection() {
    stateFlow
    .onEach { uiState ->
    searchComposablesUI?.startComposingViews(uiState)
    }.launchIn(scope)
    }
    ...
    @raulhernandezl

    View Slide

  127. SearchComposablesUI sets ComposeView
    @raulhernandezl
    @ActivityScope
    class SearchComposablesUI @Inject constructor() {
    private var composeView: ComposeView? = null // Compose content
    fun setComposeView(
    composeView: ComposeView
    ) {
    this.composeView = composeView
    }
    ...

    View Slide

  128. SearchComposablesUI sets content
    @raulhernandezl
    @ActivityScope
    class SearchComposablesUI @Inject constructor() {
    private var composeView: ComposeView? = null
    ...
    fun startComposingViews(uiState: TweetsUIState) {
    composeView?.setContent {
    TweetsWithSearchTheme { // default MaterialTheme
    ...
    }
    }
    }
    ...
    }

    View Slide

  129. SearchComposablesUI: Compose needs Composables
    @raulhernandezl
    @ActivityScope
    class SearchComposablesUI @Inject constructor() {
    private var composeView: ComposeView? = null
    ...
    fun startComposingViews(uiState: TweetsUIState) {
    this.composeView?.setContent {
    TweetsWithSearchTheme {
    StatesUI(uiState)
    }
    }
    }
    ...
    }
    @Composable

    View Slide

  130. SearchComposablesUI composes States
    @raulhernandezl
    @Composable
    fun StatesUI(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.XXXXXState -> // TODO ...
    }
    }

    View Slide

  131. Declarative (Stateless) UI
    handling alternatives
    @raulhernandezl

    View Slide

  132. @raulhernandezl
    Declarative UI
    UseCase
    dispatches
    state
    Imperative UI View Delegate
    triggers
    action
    renders
    intends
    action
    Presenter
    executes
    action
    STATE
    StateFlowHandler
    UIStateHandler
    handles UI
    state

    View Slide

  133. Presenter Use Case Repository
    View
    Network data
    source
    DB data source
    View Delegate
    View
    Listener
    COLLECT STATEFLOW
    @raulhernandezl
    Flow
    Flow
    StateFlow results
    StateFlow results
    results
    StateFlow
    UIStateHandler

    View Slide

  134. TweetsListUIFragment without SearchStateHandler
    public class TweetsListFragmentUI extends BaseFragment {
    @Inject
    SearchUIStateHandler searchUIStateHandler;
    @Inject
    SearchViewDelegate viewDelegate;
    SearchStateHandler stateHandler;
    ...
    }
    @raulhernandezl

    View Slide

  135. TweetsListUIFragment without SearchStateHandler
    @raulhernandezl
    public class TweetsListUIFragment extends BaseFragment {
    ...
    public void initStateFlowAndViews() {
    searchUIStateHandler
    .setComposeView(composeView)
    searchUIStateHandler
    .initStateFlowAndViews(viewDelegate.getStateFlow());
    }

    View Slide

  136. SearchUIStateHandler with StateFlow
    @raulhernandezl
    @ActivityScope
    class SearchUIStateHandler @Inject constructor() {
    private lateinit var stateFlow: StateFlow
    private var composeView: ComposeView? = null
    fun initStateFlowAndViews(stateFlowUI: StateFlow) {
    stateFlow = stateFlowUI
    composeView?.setContent {
    TweetsWithSearchTheme {
    StatesUI()
    }
    }
    }
    ...

    View Slide

  137. SearchUIStateHandler collect as State
    @raulhernandezl
    @ActivityScope
    class SearchUIStateHandler @Inject constructor() {
    private lateinit var stateFlow: StateFlow
    ...
    @Composable
    fun StatesUI() {
    val state: State = stateFlow.collectAsState()
    StateUIValue(state.value)
    }
    }

    View Slide

  138. Declarative (Stateless) UI
    Rendering
    @raulhernandezl

    View Slide

  139. SearchUIStateHandler composes “TweetsGrid” element
    @raulhernandezl
    @Composable
    fun StateUIValue(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState ->
    TweetsGrid(tweets = uiState.tweets)
    }
    }

    View Slide

  140. “TweetsGrid” composable
    @raulhernandezl
    @Composable
    fun TweetsGrid(tweets: List) {
    LazyVerticalGrid(modifier = Modifier.fillMaxWidth(),
    cells = GridCells.Fixed(2)
    ) {
    items( count = tweets.size,
    itemContent = { index ->
    val tweet = tweets[index]
    TweetBox(tweet = tweet, onTweetClick(tweet))
    }
    )
    }
    }
    LazyVerticalGrid
    items
    count itemContent
    TweetBox

    View Slide

  141. “onTweetClick” lambda
    @raulhernandezl
    private fun onTweetClick(
    tweet: Tweet
    ): () -> Unit {
    return { // lambda
    if (composeView != null) {
    // go to the tweet detail screen
    TweetDetails.navigateTo(composeView, tweet.title, tweet.id)
    }
    }
    }
    LazyVerticalGrid
    tweet
    TweetDetail

    View Slide

  142. “TweetBox” composable
    @raulhernandezl
    @Composable
    fun TweetBox(
    tweet: Tweet,
    onClick: () -> Unit
    ) {
    Box(modifier = Modifier
    .wrapContentWidth()
    .clickable(onClick = onClick) // go to the tweet details screen
    .border(width = 1.dp,color = Color.LightGray,shape = ...),
    contentAlignment = Alignment.Center
    ) {
    ...
    }
    }
    Box (properties)
    Modifier
    clickable border
    wrapContentWidth
    content
    Alignment
    center

    View Slide

  143. “TweetBox” Column composable
    @Composable
    fun TweetBox(...) {
    Box(...) {
    Column(
    modifier = Modifier.padding(10.dp),
    verticalArrangement = Arrangement.SpaceBetween,
    horizontalAlignment = Alignment.CenterHorizontally
    ) {
    ...
    }
    }
    }
    @raulhernandezl
    Box
    Column

    View Slide

  144. “TweetBox” Image composable’s remember
    @Composable
    fun TweetBox(...) {
    Box(...) {
    val imageUrl = tweet.images[0]
    Column( ...) {
    Image(
    painter = rememberImagePainter(
    data = imageUrl,
    onExecute = { _, _ -> true }
    ),
    builder = { placeholder(R.drawable.view_holder) }
    modifier = Modifier.size(60.dp)
    ...
    )
    ...
    }
    }
    }
    @raulhernandezl
    Box
    Column
    Image

    View Slide

  145. “TweetBox” Image composable’s contentDescription
    @Composable
    fun TweetBox(...) {
    Box(...) {
    val userName = tweet.user ?: ""
    Column( ...) {
    Image(
    ...
    contentDescription = "$userName avatar image",
    )
    ...
    }
    }
    }
    @raulhernandezl
    Box
    Column
    Image

    View Slide

  146. “TweetBox” composable’s Column elements
    @Composable
    fun TweetBox(tweet: Tweet) {
    ...
    Image(
    ...
    )
    Spacer(modifier = Modifier.height(10.dp))
    CenteredText(text = userName)
    }
    }
    }
    @raulhernandezl
    Box
    Column
    Image
    Spacer
    Text

    View Slide

  147. SearchUIStateHandler composes “CenteredText”
    @raulhernandezl
    @Composable
    fun StateUIValue(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState ->
    TweetsList(tweets = uiState.tweets)
    is TweetsUIState.LoadingUIState -> CenteredText(msg =
    stringResource(R.string.loading_feed))
    is TweetsUIState.EmptyUIState -> CenteredText(msg =
    stringResource(R.string.query, uiState.query))
    is TweetsUIState.ErrorUIState -> CenteredText(msg =
    ”Error happened: ${uiState.msg}”)
    }
    }

    View Slide

  148. “CenteredText” composable
    @raulhernandezl
    @Composable
    private fun CenteredText(msg: String) {
    Text(
    text = msg,
    modifier = Modifier.padding(16.dp)
    .wrapContentSize(Alignment.Center),
    style = MaterialTheme.typography.body1,
    overflow = TextOverflow.Ellipsis
    )
    }
    Text
    text
    modifier style
    overflow

    View Slide

  149. SearchUIStateHandler composes “TopText”
    @raulhernandezl
    @Composable
    fun StateUIValue(uiState: TweetsUIState) {
    when (uiState) {
    is TweetsUIState.ListResultsUIState ->
    TweetList(tweets = uiState.tweets)
    is TweetsUIState.LoadingUIState -> CenteredText(msg =
    stringResource(R.string.loading_feed))
    is TweetsUIState.EmptyUIState -> CenteredText(msg =
    stringResource(R.string.query, uiState.query))
    is TweetsUIState.ErrorUIState -> CenteredText(msg =
    ”Error happened: ${uiState.msg}”)
    is TweetsUIState.IdleUIState -> TopText()
    }
    }

    View Slide

  150. Cleaning up resources
    @raulhernandezl

    View Slide

  151. When using SearchStateHandler, clean up
    @raulhernandezl
    @ActivityScope
    class SearchStateHandler @Inject constructor(
    @Named("CoroutineUIScope") private val scope: CoroutineScope
    ) {
    private var searchComposablesUI: SearchComposablesUI? = null
    private var tweetsListUI: TweetsListUI? = null
    fun cancel() {
    scope.cancel()
    searchComposablesUI = null // Declarative
    tweetsListUI = null // Imperative
    }

    View Slide

  152. When using SearchUIStateHandler, clean up
    @raulhernandezl
    @ActivityScope
    class SearchUIStateHandler @Inject constructor() {
    private var composeView: ComposeView? = null
    fun destroyViews() {
    composeView = null // Declarative UI
    }
    }

    View Slide

  153. TweetsListFragmentUI clean up
    @raulhernandezl
    public class TweetsListFragmentUI extends BaseFragment {
    @Inject SearchViewDelegate viewDelegate;
    @Inject SearchStateHandler stateFlowHandler;
    @Inject SearchUIStateHandler searchUIStateHandler;
    @Override
    public void onDestroyView() {
    viewDelegate.cancel(); // cancels View & other Coroutines jobs
    stateFlowHandler.cancel(); // cancels StateFlow collection
    searchUIStateHandler.destroyViews(); // destroy views refs
    super.onDestroyView();
    }
    }

    View Slide

  154. Declarative ready with
    Unidirectional Data Flow!
    @raulhernandezl

    View Slide

  155. Tweets Search
    sample app with
    Compose
    ● Initial text
    ● Loading text
    ● Empty results text
    ● List of Results
    ● Error text
    @raulhernandezl

    View Slide

  156. Lessons learned
    @raulhernandezl

    View Slide

  157. Interoperability
    @raulhernandezl

    View Slide

  158. Recomposition runs more times than
    one
    @raulhernandezl

    View Slide

  159. remember would save a previous state
    in the View
    @raulhernandezl

    View Slide

  160. Composition Local is the “simplest”
    way to pass Data Flow to the tree and
    its children
    @raulhernandezl

    View Slide

  161. Composables should be
    Side Effects free
    @raulhernandezl

    View Slide

  162. UDF likes states
    @raulhernandezl

    View Slide

  163. Imperative vs Declarative
    programming
    @raulhernandezl

    View Slide

  164. Why Compose?
    @raulhernandezl

    View Slide

  165. Pros & Cons
    Compose Parallel order & Frequently
    (classic) Views Sequential order
    Compose Stateless = Immutable = Deterministic
    (classic) Views
    Stateful = Mutable = Unpredictable
    Compose Kotlin
    (classic) Views Java / Kotlin
    @raulhernandezl

    View Slide

  166. Performance improvements
    @raulhernandezl

    View Slide

  167. Testability
    @raulhernandezl

    View Slide

  168. Reusability of UI components
    @raulhernandezl

    View Slide

  169. Experimentation of new ways of
    working
    @raulhernandezl

    View Slide

  170. Next Steps
    @raulhernandezl

    View Slide

  171. Start adopting explicit
    state declarations TODAY
    @raulhernandezl

    View Slide

  172. References
    @raulhernandezl
    Getting ready for Declarative UIs series
    • Part 1 Unidirectional Data Flow
    • Part 2 Implementing Unidirectional Data Flow
    • Part 3 Why Declarative UIs on Android?
    • Synchronous communication with the UI using StateFlow

    View Slide

  173. Special thanks
    @raulhernandezl
    Nick Butcher

    View Slide

  174. Thank you.
    @raulhernandezl

    View Slide

  175. Questions?
    @raulhernandezl

    View Slide

  176. (Used) Dependencies
    note: this may change really quickly, read official sources to verify
    @raulhernandezl

    View Slide

  177. Gradle dependencies for Kotlin Coroutines
    @raulhernandezl
    build.gradle
    ...
    // Kotlin Coroutines
    implementation
    "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
    implementation
    "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
    // Kotlin Standard Library
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_stdlib_version"
    kotlin_coroutines_version = '1.5.0'
    kotlin_stdlib_version = '1.5.10'

    View Slide

  178. Gradle’s app dependencies for Jetpack Compose
    @raulhernandezl
    app/build.gradle
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.runtime:runtime:$compose_version"
    implementation "androidx.compose.foundation:foundation-layout:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    implementation "io.coil-kt:coil-compose:$coil_compose"
    compose_version = '1.0.0-rc02'
    coil_compose = '1.3.0'

    View Slide

  179. Gradle’s project dependencies for Jetpack Compose
    @raulhernandezl
    build.gradle

    dependencies {
    classpath 'com.android.tools.build:gradle:7.1.0-alpha03'
    classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10'
    }

    View Slide

  180. Gradle’s wrapper dependencies for Jetpack Compose
    @raulhernandezl
    gradle/wrapper/gradle-wrapper.properties
    ...
    distributionUrl=https://services.gradle.org/distributions/gradle-7.0-bin
    .zip

    View Slide

  181. Android Studio versioning
    @raulhernandezl
    Android Studio Bumblebee | 2021.1.1 Canary 3

    View Slide