Asynchronous Task Execution Layer Architecture

    In mobile applications of social networks, the user likes, writes a comment, then flips through the feed, starts the video and puts the like again. All this is fast and almost simultaneous. If the implementation of the business logic of the application is completely blocking, then the user will not be able to go to the tape until the like for recording with seals is uploaded. But the user will not wait, therefore, in most mobile applications asynchronous tasks work, which are started and completed independently of each other. The user performs several tasks at the same time and they do not block each other. One asynchronous task starts and runs while the user starts the next.



    In the decoding of the report of Stepan Goncharov on  AppsConfwe will touch upon asynchrony: we will delve deeper into the architecture of mobile applications, discuss why we should select a separate layer for performing asynchronous tasks, we will analyze the requirements and existing solutions, we will go through the pros and cons, and consider one of the implementations of this approach. We also learn how to manage asynchronous tasks, why each task has its own ID, what are the execution strategies and how they help simplify and speed up the development of the entire application.


    About the speaker: Stepan Goncharov ( stepango ) works at Grab - it's like Uber, but in Southeast Asia. He has been involved in Android development for more than 9 years. Interested in Kotlin since 2014, and since 2016 - uses it in the prod. Organized by Kotlin User Group in Singapore. This is one of the reasons why all code examples will be on Kotlin, and not because it is fashionable.

    We’ll look at one approach to designing the components of your application. This is a guide to action for those who want to add new components to the application, conveniently design them, and then expand them. iOS developers can use the iOS approach. The approach also applies to other platforms. I have been interested in Kotlin since 2014, so all the examples will be in this language. But don't worry - you can write the same thing in Swift, Objective-C, and other languages.

    Let's start with the problems and disadvantages of Reactive Extensions . Problems are typical for other asynchronous primitives, so we say RX - keep in mind the future and promise, and everything will work similarly.

    RX issues


    High entry threshold . RX is quite complex and large - it has 270 operators, and it is not easy to teach the whole team how to use them correctly. We will not discuss this problem - it is beyond the scope of the report.

    In RX, you must manually manage your subscriptions, as well as monitor the life cycle of the application . If you have already subscribed to Single or Observable, you  cannot compare it with another SIngle , because you will always receive a new object and there will always be different subscriptions for runtime. In RX there is no way to compare subscriptions and streams .

    We will try to solve some of these problems. We will solve each problem once, and then reuse the result.

    Problem number 1: performing one task more than once


    A common problem in development is unnecessary work and repetition of the same tasks more than once. Imagine that we have a form for entering data and a save button. When pressed, a request is sent, but if you click several times while the form is being saved, then several identical requests will be sent. We gave the button to test QA, they pressed 40 times in one second - we received 40 requests, because, for example, the animation did not have time to work.

    How to solve a problem? Each developer has his own favorite approach for solving: one will stick debounce, the other will block the button just in case through clickable = false. There is no general approach, so these bugs will either appear or disappear from our application. We solve the problem only when QA tells us: “Oh, I clicked here, and it broke”!

    A scalable solution?


    To avoid such situations, we will wrap RX or another asynchronous framework - we will add IDs to all asynchronous operations . The idea is simple - we need some way to compare them, because usually this method is not in the frameworks. We can complete the task, but we don’t know whether it has already been completed or not.

    Let's call our wrapper “Act” - other names are already taken. To do this, create a small typealiasand simple interfaceone in which there is only one field:

    typealias Id = String
    interface Act { 
        val id: Id
    }

    This is convenient and slightly reduces the amount of code. Later, if String does not like it, we will replace it with something else. In this small piece of code, we observe a funny fact.

    Interfaces may contain property.

    For programmers who come from Java, this is unexpected. Usually they add methods to the interface getId(), but this is not a good solution, from the point of view of Kotlin.

    How will we design?


    A small digression. When designing, I adhere to two principles. The first is to break down the component requirements and implementation into small pieces . This allows granular control over code writing. When you create a large component and try to do everything all at once, this is bad. Usually this component does not work and you start to insert crutches, so I urge you to write in small controlled steps and enjoy it. The second principle is to check the operability after each step and  repeat the procedure again.

    Why is not enough ID?


    Let's get back to the problem. We took the first step - we added an ID, and everything was simple - the interface and the field. This did not give us anything, because the interface does not contain any implementation and does not work on its own, but allows you to compare operations.

    Next, we will add components that will allow us to use the interface and understand that we want to execute some kind of request a second time when this is not necessary. The first thing we will do is introduce new abstractions .

    Introducing New Abstractions: MapDisposable


    It is important to choose the right name and abstraction familiar to developers who work in your code base. Since I have examples on RX, we will use the RX concept and names similar to those used by the library developers. So we can easily explain to our colleagues what they did, why, and how it should work. To select a name, see the CompositeDiposable documentation .

    Let's create a small MapDisposable interface that contains information about current tasks and  calls dispose () on deletion . I will not give the implementation, you can see all the sources on my GitHub .

    We call MapDisposable this way because the component will work like a Map, but it will have CompositeDiposable properties.

    Introducing New Abstractions: ActExecutor


    The next abstract component is ActExecutor. It starts or does not start new tasks, depends on MapDisposable and delegates error handling. How to choose a name - see the documentation .

    Take the closest analogy from the JDK. It has an Executor in which you can pass thread and do something. It seems to me that this is a cool component and it is well designed, so let's take it as a basis.

    We create ActExecutor and a simple interface for it, adhering to the principle of simple little steps. The name itself says that it is a component to which we transmit something and it starts to do something. ActExecutor has one method in which we pass Actand, just in case, handle errors, because without them there is no way.

    interface ActExecutor {
        fun execute(
            act: Act,
            e: (Throwable) -> Unit = ::logError)
    }
    interface MapDisposable {
        fun contains(id: Id): Boolean
        fun add(id: Id, disposable: () -> T)
        fun remove(id: Id)
    }

    MapDisposable is also limited: take the Map interface and copy methods from it contains, addand remove. The method adddiffers from Map: the second argument is the lambda for beauty and convenience. The convenience is that we can synchronize the lambda to prevent unexpected race conditions . But we will not talk about this, we will continue about architecture.

    Interface Implementation


    We have declared all the interfaces and will try to implement something simple. Take CompletableAct and  SingleAct .

    class CompletableAct (
        override val id: Id,
        override val completable: Completable
    ) : Act
    class SingleAct(
        override val id: Id,
        override val single: Single
    ) : Act 

    CompletableAct is a wrapper over Completable. In our case, it simply contains an ID - which is what we need. SingleAct is almost the same. We can implement Maybe and Flowable as well, but dwell on the first two implementations.

    For Single, we specified the Generic type . As a Kotlin developer, I prefer to use just such an approach.

    Try to use Non-Null Generics.

    Now that we have a set of interfaces, we implement some logic to prevent the execution of the same requests.

    class ActExecutorImpl (
        val map: MapDisposable
    ): ActExecutor {
        fun execute(
            act: Act,
            e: (Throwable) -> Unit
        ) = when {
            map.contains(act.id) -> {
                log("${act.id} - in progress")
            }
            else
                startExecution(act, e)
                log("${act.id} - Started")
            }
        }

    We take a Map and check if there is a request in it. If not, we start executing the request and add it to the Map just at runtime. After execution with any result: error or success, delete the request from Map.

    For very attentive - there is no synchronization, but synchronization is in the source code on GitHub.

    fun startExecution(act: Act, e: (Throwable) -> Unit) {
        val removeFromMap = { mapDisposable.remove(act.id) }
        mapDisposable.add(act.id) {
        when (act) {
            is CompletableAct -> act.completable
                .doFinally(removeFromMap)
                .subscribe({}, e)
            is SingleAct<*> -> act.single
                .doFinally(removeFromMap)
                .subscribe({}, e)
            else -> throw IllegalArgumentException()
        }
    }

    Use lambdas as the last argument to improve code readability. It is beautiful and your colleagues will thank you.

    We’ll use some more Kotlin chips and add extension functions for Completable and Single. With them, we don’t have to look for a factory method to create a CompletableAct and SingleAct - we will create them through extension functions.

    fun Completable.toAct(id: Id): Act =
        CompletableAct(id, this)
    fun  Single.toAct(id: Id): Act =
        SingleAct(id, this)

    Extension functions can be added to any class.

    Result


    We have implemented several components and very simple logic. Now the main rule that we must follow is not to force a subscription by hand . When we want to execute something - we give it through Executor. As well as with thread - no one starts them themselves.

    fun act() = Completable.timer(2, SECONDS).toAct("Hello")
    executor.apply {
        execute(act())
        execute(act())
        execute(act())
    }
            Hello - Act Started
            Hello - Act Duplicate
            Hello - Act Duplicate
            Hello - Act Finished

    We once agreed within the team, and now there is always a guarantee that the resources of our application will not be spent on the execution of identical and unnecessary requests.

    The first problem was solved. Now let's expand the solution to give it flexibility.

    Problem number 2: what task to cancel?


    As well as in cases where it is necessary to cancel a subsequent request , we may need to cancel the previous one. For example, we edited the information about our user for the first time and sent it to the server. For some reason, the dispatch took a long time and did not complete. We edited the user profile again and sent the same request a second time. In this case, it makes no sense to generate a special ID for the request - the information from the second attempt is more relevant, and the  previous request is canceled .

    The current solution will not work, because it will always cancel the execution of the request with relevant information. We need to somehow expand the solution to get around the problem and add flexibility. To do this, understand what we all want? But we want to understand what task to cancel, how not to copy-paste and what to call it.

    Add components


    We call query behavior strategies and create two interfaces for them: StrategyHolder and  Strategy . We also create 2 objects that are responsible for which strategy to apply.

    interface StrategyHolder {
        val strategy: Strategy
    }
    sealed class Strategy
    object	
    KillMe : Strategy()
    object	
    SaveMe : Strategy()

    I do not use enum  - I like the sealed class more . They are lighter, consume less memory, and they are easier and more convenient to expand.

    The sealed class is easier to extend and write shorter.

    Updating Existing Components


    At this point, everything is simple. We had a simple interface, now it will be the heir to StrategyHolder. Since these are interfaces, there is no problem with inheritance. In the implementation of CompletableAct, we will insert one more overrideand add the default value there to make sure that the changes will remain compatible with the existing code.

    interface Act : StrategyHolder {
        val id: String
    }
    class CompletableAct(
        override val id: String,
        override val completable: Completable,
        override val strategy: Strategy = SaveMe
    ) : Act

    Strategies


    I chose the SaveMe strategy, which seems obvious to me. This strategy only cancels the following requests - the first request will always live until it completes.

    We worked a little on our implementation. We had a execute method, and now we have added a strategy check there.

    • If the SaveMe strategy  is the same as what we did before, then nothing has changed.
    • If the strategy is KillMe  , kill the previous request and launch a new one.

    override fun execute(act: Act, e: (Throwable) -> Unit) = when {
        map.contains(act.id) -> when (act.strategy) {
            KillMe -> {
                map.remove(act.id)
                startExecution(act, e)
            }
            SaveMe -> log("${act.id} - Act duplicate")
        }
        else -> startExecution(act, e)
    }

    Result


    We were able to easily manage strategies by writing a minimum of code. At the same time, our colleagues are happy, and we can do something like this.

    executor.apply {
        execute(Completable.timer(2, SECONDS)
            .toAct("Hello", KillMe))
        execute(Completable.timer(2, SECONDS)
            .toAct("Hello", KillMe))
        execute(Completable.timer(2, SECONDS)
            .toAct("Hello«, KillMe))
    }
            Hello - Act Started
            Hello - Act Canceled
            Hello - Act Started
            Hello - Act Canceled
            Hello - Act Started
            Hello - Act Finished

    We create an asynchronous task, pass the strategy, and every time we start a new task, all the previous ones, and not the next ones, will be canceled.

    Problem number 3: strategies are not enough


    Let's move on to one interesting problem that I encountered on a couple of projects. We will expand our solution to deal with cases more complicated. One of these cases, especially relevant for social networks, is “like / dislike” . There is a post and we want to like it, but as developers we do not want to block the entire UI, and show the dialog in full screen with loading until the request is completed. Yes, and the user will be unhappy. We want to deceive the user: he presses the button and, as if the like has already happened - a beautiful animation has begun. But in fact, there was no like - we wait until the deception becomes true. To prevent fraud, we must transparently handle dislike for the user.

    It would be nice to handle this correctly so that the user gets the desired result. But it’s difficult for us, as developers, to deal with different, mutually exclusive requests each time  .

    There are too many questions. How to understand that queries are related? How to store these connections? How to handle complex scripts and not copy-paste? How to name new components? The tasks are complex, and what we have already implemented is not suitable for the solution.

    Groups and strategies for groups


    Create a simple interface called GroupStrategyHolder . It is a little more complicated - two fields instead of one.

    interface GroupStrategyHolder {
        val groupStrategy: GroupStrategy
        val groupKey: String
    }
    sealed class GroupStrategy
    object Default : GroupStrategy()
    object KillGroup : GroupStrategy()

    In addition to the strategy for a specific request, we introduce a new entity - a group of requests. This group will also have strategies. We will consider only the simplest option with two strategies: Default  - the default strategy when we do nothing with queries, and  KillGroup  - kills all existing queries from the group and launches a new one.

    interface Act : StrategyHolder, GroupStrategyHolder {
        val id: String
    }
    class CompletableAct(
        override val id: String,
        override val completable: Completable,
        override val strategy: Strategy = SaveMe,
        override val groupStrategy: GroupStrategy = Default
        override val groupKey: String = ""
    ) : Act
    

    We repeat the steps that I spoke about earlier: we take the interface, expand and add two additional fields to CompletableAct and SingleAct.

    Update implementation


    We return to the Execute method. The third task is more complicated, but the solution is quite simple: we check the group strategy for a specific request and, if it’s KillGroup, we kill the whole group and execute the usual logic.

    MapDisposable -> GroupDisposable
    ...
    override fun execute(act: Act, e: (Throwable) -> Unit) {
        if (act.groupStrategy == KillGroup)
            groupDisposable.removeGroup(act.groupKey)
        return when {
            groupDisposable.contains(act.groupKey, act.id) ->
                when (act.strategy) {
                    KillMe -> {
                        stop(act.groupKey, act.id)
                        startExecution(act, e)
                    }
                    SaveMe -> log("${act.id} - Act duplicate")
                }
            else -> startExecution(act, e)
        }
    }

    The problem is complex, but we already have a fairly adequate infrastructure - we can expand it and solve the problem. If you look at our result, what now do we need to do?

    Result


    fun act(id: String)= Completable.timer(2, SECONDS).toAct(
        id = id,
        groupStrategy = KillGroup,
        groupKey = "Like-Dislike-PostId-1234"
    )
    executor.apply {
        execute(act(“Like”))
        execute(act(“Dislike”))
        execute(act(“Like”))
    }
            Like - Act Started
            Like - Act Canceled
            Dislike - Act Started
            Dislike - Act Canceled
            Like - Act Started
            Like - Act Finished

    If we need such complex queries, we add two fields: groupStrategy and group ID. group ID is a specific parameter, because in order to support many parallel like / dislike requests, you need to create a group for each pair of requests that belong to the same object. In this case, you can name the group Like-Dislike-PostId and add the post ID there. Each time we like the neighboring posts, we will be sure that everything works correctly for the previous post, and for the next.

    In our synthetic example, we are trying to execute a like-dislike-like sequence. When we perform the first action, and then the second - the previous one is canceled and the next like cancels the previous dislike. This is what I wanted.

    In the last example, we used named parameters to create Acts. This helps cool code readability, especially when there are a lot of parameters.

    For easier reading, use named parameters.

    Architecture


    Let's see how this decision can affect our architecture. On projects, I often see that the View Model or Presenter take on a lot of responsibility, such as hacks, to somehow handle the situation with like / dislike. Usually all this logic in the View Model: a lot of duplicate code with button locking, LifeCycle handlers, subscriptions.



    Everything that our Executor is doing now was once in either Presenter or View Model. If the architecture is mature, the developers could take this logic to some kind of interactors or use-cases, but the logic was duplicated in several places.

    After we adopted Executor, the View Model becomes simpler and all the logic is hidden from them. If you once brought this to Presenter and the interactor, then you know that the interactor and Presenter are getting easier. In general, I was satisfied.



    What else to add?


    Another plus of the current solution is that it is extensible. What else would we like to add as developers who work on a mobile application and struggle with bugs and lots of concurrent requests every day?

    Opportunities


    The implementation of the life cycle remained behind the scenes , but as mobile developers, we all always think about this and worry so that nothing will flow away. I would like to save and restore application restart requests.

    Chains of calls. Due to the wrapping of RX chains, it becomes possible to serialize them, because by default RX does not serialize.

    Few people know how many concurrent requests are running at a particular point in time in their applications. I would not say that this is a big problem for small and medium-sized applications. But for a large application that does a lot of work in the background, it's nice to understand the causes of crashes and user complaints. Without additional infrastructure, developers simply do not have information to understand the reason: maybe the reason is in the UI, or maybe in a huge number of constant requests in the background. We can expand our solution and add some kind of metrics .

    Let's consider the possibilities in more detail.

    Life cycle processing


    class ActExecutorImpl(
        lifecycle: Lifecycle
    ) : ActExecutor {
        inir {
        lifecycle.doOnDestroy { cancelAll() }
        }
    ...

    This is an example of a life cycle implementation. In the simplest case - with Destroyfragments or canceled with Activity, - we  pass the lifecycle-handler to our Executor , and when the onDestroy event occurs, we delete all requests . This is a simple solution that eliminates the need to copy-paste similar code in View Models. LifeData does roughly the same thing.

    Saving / Restoring


    Since we have wrappers, we can create separate classes for Acts , inside which there will be logic for creating asynchronous tasks. Further, we can save this name to the database and  restore it from the database at application startup using the factory method or something similar.

    At the same time, we will get the opportunity of offline work and we will restart the requests that were completed with errors when the Internet appears. In the absence of the Internet or with request errors, we save them to the database, and then restore and execute it again. If you can do this with regular RX without additional wrappers, please write in the comments, it would be interesting.

    Call chains


    We can also bind our Acts . Another extension option is to run query chains . For example, you have one entity that needs to be created on the server, and another entity, which depends on the first one, must be created exactly at the moment when we are sure that the first request was completed successfully. This can also be done. Of course, this is not so trivial, but having a class that controls the launch of all asynchronous tasks is possible. Using bare RX is harder to do.

    Metrics


    It is interesting to see how many parallel queries are performed on average in the background . Having metrics, you can understand the cause of user complaints about lethargy. At a minimum, we can exclude from the list of reasons the execution in the background of what we did not expect.

    When we can start and stop requests in one place, we can add, for example, tracking the average execution time and see that after some release we have, for some reason, an average of 10% longer requests are processed. This is also good information, especially for large projects.

    Conclusion


    Designing application components  is a complex, long and painstaking job. “Designing” a large piece usually does not work right away. Any complex system evolves from a simpler one, and if you want to design a complex system at a time, then it most likely will never work.

    When you develop solutions for mobile applications, try to do small and simple iterations that you can validate. A common problem - you start to refactor something big, done, checked, nothing works. Why it does not work is unclear. The changes are big, you will try to roll back and still iterate in small pieces. So it’s better to go in small steps right away. Small and simple iterations are much easier to control .

    A programming language can help in design . Use cool Kotlin chips to help you and your team better understand your language and make better use of your tool. For example, to expand successful solutions in design .

    As long as reports are published on the blog with AppsConf 2018, the Program Committee accepts new ones into the conference program for AppsConf 2019 . The list of accepted reports already has 38 positions: architecture, Android, UX technologies, scaling, business processes and, of course, Kotlin.

    Follow the announcements, subscribe to the youtube channel and the newsletter and are waiting for you on April 22-23 at the conference of mobile developers.

    Also popular now: