1, the background

In the pre-holiday interview, I was asked how to schedule the Android startup task dependency. At that time, I gave a plan casually. Later, I thought it was interesting, so I spent a day to write one. The library is open sourced on Github:

Github.com/Shouheng88/…

Just look at Jetpack Startup before writing this library. After all, if the library is already good enough, there’s no need to build another one myself. The biggest drawback of the library so far, in my opinion, is that all tasks in the library are triggered and executed in the main thread, whereas we usually put tasks in asynchronous threads to optimize startup performance. So, at best, Jetpack’s libraries can only handle the dependencies of your tasks.

If you want to support asynchronous task execution, you first need to figure out how to prioritize tasks. Initially I also thought of using and issuing latches in packages, but there was a problem with that. That is, the CAS is used to block while the lock is executed, which wastes thread resources. If this takes up CPU, it will affect the execution of our other threads. So, in my library, I use a non-blocking event notification mechanism. This notifies all dependent tasks when a task is finished. When all dependencies of a task have been executed, execute your own task. Of course, asynchronous task execution behaves differently for different parameter thread pools, so I also provide methods to customize thread pools.

In addition, I used an annotation processor while developing the library. You can declare your own tasks through annotations, and your tasks will be discovered and concatenated automatically during compilation. There is one more option for initialization compared to other frameworks.

2, structure,

During the development, I divided the task scheduling and startup tool into two independent modules, so that the task scheduling tool can also be used separately. Later, with the addition of annotation-driven logic, two more modules were added. Therefore, now the modules and functions are as follows:

Scheduler: a task scheduling tool startup: a task scheduling tool that packages startup-annotation: defines startup-compiler: a annotation compilerCopy the code

3. Scheduler

Let’s look at how the task scheduler works.

3.1 Task Encapsulation

The first is the definition of the task. In my project I used ISchedulerJob to define tasks.

interface ISchedulerJob {

    fun threadMode(a): ThreadMode

    fun dependencies(a): List<Class<out ISchedulerJob>>

    fun run(context: Context)
}
Copy the code

It defines three methods, which are:

  • threadMode()Used to specify the thread to execute the task
  • dependencies()Used to specify the task that the current task depends on
  • run()The method by which you initialize the task execution

Secondly, the logic of real task distribution is accomplished through the Dispatcher. Based on the dependencies between tasks, we can build a topology. The first task to be executed during task execution is the root of the topology. This is the node where the dependencies() method is null. So, here, we first monitor the topology for the presence of rings. Then, you just need to find the root and execute the task from the root.

3.2 test

For loop monitoring, if spatial complexity is not considered, we can use Set to find loop dependencies:

private fun checkDependencies(a) {
    val checking = mutableSetOf<Class<out ISchedulerJob>>()
    val checked = mutableSetOf<Class<out ISchedulerJob>>()
    val schedulerMap = mutableMapOf<Class<ISchedulerJob>, ISchedulerJob>()
    schedulerJobs.forEach { schedulerMap[it.javaClass] = it }
    schedulerJobs.forEach { schedulerJob ->
        checkDependenciesReal(schedulerJob, schedulerMap, checking, checked)
    }
}

private fun checkDependenciesReal(
    schedulerJob: ISchedulerJob,
    map: Map<Class<ISchedulerJob>, ISchedulerJob>,
    checking: MutableSet<Class<out ISchedulerJob>>,
    checked: MutableSet<Class<out ISchedulerJob>>
) {
    if (checking.contains(schedulerJob.javaClass)) {
        // Cycle detected.
        throw SchedulerException("Cycle detected for ${schedulerJob.javaClass.name}.")}if(! checked.contains(schedulerJob.javaClass)) { checking.add(schedulerJob.javaClass)if (schedulerJob.dependencies().isNotEmpty()) {
            schedulerJob.dependencies().forEach {
                if(! checked.contains(it)) {valjob = map[it] ? :throw SchedulerException(String.format("dependency [%s] not found", it.name))
                    checkDependenciesReal(job, map, checking, checked)
                }
            }
        }
        checking.remove(schedulerJob.javaClass)
        checked.add(schedulerJob.javaClass)
    }
}
Copy the code

The logic here is similar to the loop monitoring logic in Jetpack. Here, two redundant data structures are used to record the detected and detected task nodes respectively. If a task to be detected is detected, it indicates the existence of a loop.

3.3 Starting a Task

Before starting a task, it is necessary to establish a data structure according to the dependency relationship between tasks. Simply speaking, it is necessary to know which tasks depend on the current task and which tasks depend on it.

private fun buildDispatcherJobs(a) {
    roots.clear()

    // Build the map from scheduler class type to dispatcher job.
    val map =  mutableMapOf<Class<ISchedulerJob>, DispatcherJob>()
    schedulerJobs.forEach {
        val dispatcherJob = DispatcherJob(this.globalContext, executor, it)
        map[it.javaClass] = dispatcherJob
    }

    // Fill the parent field for dispatcher job.
    schedulerJobs.forEach { schedulerJob ->
        valdispatcherJob = map[schedulerJob.javaClass]!! schedulerJob.dependencies().forEach { dispatcherJob.addParent(map[it]!!) }}// Fill the children field for dispatcher job.
    schedulerJobs.forEach { schedulerJob ->
        val dispatcherJob = map[schedulerJob.javaClass]!!
        dispatcherJob.parents().forEach {
            it.addChild(dispatcherJob)
        }
    }

    // Find roots.
    schedulerJobs.filter {
        it.dependencies().isEmpty()
    }.forEach {
        val dispatcherJob = map[it.javaClass]!!
        roots.add(dispatcherJob)
    }
}
Copy the code

Then, according to the dependencies of tasks, find the parent task of each task and call its addParent() method. An AtomicInteger is used to count the number of its parent tasks in the DispatcherJob. Then, the relationship between sub-tasks is maintained through the parent tasks of each task. Finally, the root of the topology is found according to the dependency of the task. This way, we can execute the entire topology from the root.

3.4 Task notification mechanism

Execute () : execute() : execute() : execute() : execute() : execute() : execute() : execute() : execute()

override fun execute(a) {
    val realJob = {
        // Run the task.
        job.run(context)
        // Handle for children.
        children.forEach { it.notifyJobFinished(this)}}try {
        if (job.threadMode() == ThreadMode.MAIN) {
            // Cases for main thread.
            if (Thread.currentThread() == Looper.getMainLooper().thread) {
                realJob()
            } else {
                Handler(Looper.getMainLooper()).post { realJob() }
            }
        } else {
            // Cases for background thread.
            executor.execute { realJob() }
        }
    } catch (e: Throwable) {
        throw SchedulerException(e)
    }
}
Copy the code

Here the execution logic of the task is wrapped in a lambda method. If it is the main thread, execution can be performed based on the current thread state or post to the main thread. If it is an asynchronous task, it is thrown into the thread pool for execution.

The notifyJobFinished() method is used to notify all subtasks when a task’s work is finished. When a task is completed, the counter is reduced by 1. When all dependent tasks are completed, the counter starts to execute its own task, so that the task can be scheduled by event instead of blocking:

override fun notifyJobFinished(job: IDispatcherJob) {
    if (waiting.decrementAndGet() == 0) {
        // All dependencies finished, commit the job.
        execute()
    }
}
Copy the code

4. Initiators

For starters, you have three options. Use a ContentProvider like Jetpack, declare the task yourself, or use the @startupJob annotation to declare the task.

For content providers, the principle is simple: scan the custom meta-data. ContentProvider declaration in the onCreate() method of the custom ContentProvider. As one of the four components, ContentProvider requires certain performance to be established. In addition, the default ContentProvider runs in the main process, so if your application uses multiple processes, the default ContentProvider will not initialize your child process unless you specify its process.

So, in addition to contentProviders, you can use manual declarations,

AndroidStartup.newInstance(this).jobs(
   CrashHelperInitializeJob(),
   ThirdPartLibrariesInitializeJob(),
   DependentBlockingBackgroundJob(),
   BlockingBackgroundJob()
).launch()
Copy the code

In addition, I purposely added annotations to declare tasks. It’s easy to use, you just need to use annotation declarations on your own tasks, such as:

@StartupJob
class BlockingBackgroundJob : ISchedulerJob {

    override fun threadMode(a): ThreadMode = ThreadMode.BACKGROUND

    override fun dependencies(a): List<Class<out ISchedulerJob>> = emptyList()

    override fun run(context: Context) {
        Thread.sleep(5_000L) // 5 seconds
        L.d("BlockingBackgroundJob done! ${Thread.currentThread()}")}}Copy the code

It also works relatively simple, because when you call AndroidStartup’s scanAnnotations() method, it calls JobHunter’s method by reflection to fetch all tasks. At compile time, we provide an implementation for this interface, and all scanned tasks are initialized and returned in that implementation.

conclusion

That’s how this library works.