[译] 从Service到WorkManager

11,398 阅读10分钟

随着Android版本的不断更新,如何正确的处理后台任务变得越来越复杂。因此, Google发布了 WorkManager(作为JetPack的一部分)来帮助开发者解决这一难题。

在学习WorkManager之前,首先得知道我们为什么需要它。本文将从以下三部分来阐述:

  1. Android系统内存相关基础知识
  2. 现有的解决方案
  3. WorkManager

1. Android Memory

Android系统的内核是基于Linux内核的,它与其他那些基于Linux内核的系统的主要差别在于Android系统没有交换空间(Swap space)。当系统内存资源已被耗尽,但是又有额外的内存资源请求的时候,内存中不活动的页面会被移动到交换空间。交换空间是磁盘上的一块区域,因此其访问速度比物理内存慢。

鉴于此,Android系统引入了OOM( Out Of Memory ) Killer 来解决内存资源被耗尽的问题。它的作用是根据进程所消耗的内存大小以及进程的“visibility state”来决定是否杀死这个进程,从而达到释放内存的目的。

Activity Manager会给不同状态下的进程设置相对应的oom_adj 值。下面是一些示例:

# Define the oom_adj values for the classes of processes that can be
# killed by the kernel.  These are used in ActivityManagerService.
    setprop ro.FOREGROUND_APP_ADJ 0    //前台进程
    setprop ro.VISIBLE_APP_ADJ 1       //可见进程
    setprop ro.SECONDARY_SERVER_ADJ 2  //次要服务
    setprop ro.BACKUP_APP_ADJ 2        //备份进程
    setprop ro.HOME_APP_ADJ 4          //桌面进程
    setprop ro.HIDDEN_APP_MIN_ADJ 7    //后台进程
    setprop ro.CONTENT_PROVIDER_ADJ 14 //内容供应节点
    setprop ro.EMPTY_APP_ADJ 15        //空进程

进程的omm_adj 值越大,它被 OOM killer 杀死的可能性越大。OOM killer是依据系统空闲的内存空间大小和omm_adj阈值的组合规则来杀死进程的。比如,当空闲的内存空间大小小于X1时,杀死那些omm_adj值大于Y1的进程。它的基本处理流程如下图所示:

到现在为止,我希望你知道两点:

  1. 你的应用消耗的内存越少,它被系统强行杀死的可能性越小,也就是说你的应用存活的时间越长。
  2. 你必须清楚的了解应用的各种不同状态。当你的应用退到后台,但是它需要继续执行任务时,你就必须使用Service。

A Service is an application component that can perform long-running operations in the background, and it does not provide a user interface.

使用service的理由如下:

  1. 告诉系统你的应用有一个需要长时间执行的任务,并获得任务所属进程所对应的omm_adj值。
  2. 它是Android应用程序的四大组件之一(其它三个组件分别是BroadcastReceiver, Activity, ContentProvider)。
  3. Service能运行在独立的进程。

但是也存在一个缺点:我开发了自己的第一个应用,它竟然在不到3个小时的时间内将电池电量从100%消耗至0%,因为我的应用开启了一个service:每3分钟从服务器中获取数据。

那时候我还只是一个年轻的没有经验的开发者。但不知为何,6年之后,仍然有许多未知的应用程序在做着同样的事情。

每一位开发者可以毫无限制地在后台执行着任何他们想做的操作。google也意识到了这一点,并试图采取一些改进的措施。

Marshmallow 开始,然后是 Nougat , Android系统引入了休眠模式 (Doze mode)

何为休眠模式?简而言之,当用户关闭了手机屏幕之后,系统会自动进入休眠模式,禁止所有应用的网络请求、数据同步、GPS、闹钟、wifi扫描等功能,直到用户重新点亮屏幕或者手机接入了电源,这样可以有效节省手机的电量。

但这感觉像是沧海一粟,因此从Android Oreo (API 26) 开始,Google 做了进一步改进:如果一个应用的目标版本为Android 8.0,当它在某些不被允许创建后台服务的场景下,调用了Service的startService()方法,该方法会抛出IllegalStateException。这个问题可以通过调整targeting SDK的值来解决,一些知名应用的target API都是22,因为他们不愿意处理运行时权限。

但是,接下来你会发现:

  • 2018年8月: 所有新开发应用的target API level必须是26(Android 8.0)甚至更高。
  • 2018年11月: 所有已发布应用的target API level必须更新至26甚至更高。
  • 2019年起: 在每一次发布新版本的Android系统之后,所有新开发以及待更新的应用都必须在一年内将target API level调整至对应的系统版本甚至更高。

说了这么多 - (我相信你会得出同样的结论):

我们所熟知的Servcie已经被弃用了,因为它不再被允许在后台执行长时间的操作,而这却是它最初被设计出来的目的。

除了Foreground service之外,我们已经没有任何理由再去使用Service了。

2. I have a network call. What is out there:

首先举个简单的例子:有一个简单的网络请求,它能下载几千字节的数据。最简单的方法(并不正确的方法)就是开启一个单独的线程来执行这一请求。

int threads = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(threads);
executor.submit(myWork);

再考虑下登录场景。用户填写了邮箱、密码,然后点击了登录按钮。用户的手机是3G网络,信号很差,接着他走进了电梯。

当应用正在执行登录网络请求的时候,用户接了一个电话。

OkHttp 默认的超时时间很长

connectTimeout = 10_000;
readTimeout = 10_000;
writeTimeout = 10_000;

通常我们都会设置网络请求重试的次数为3

因此, 最坏的情况是: 3 * 30 = 90 秒.

现在请回答一个问题 ——

登录成功了吗?

当你的应用退到后台之后,你就什么都不知道了。正如我们所了解的,你不能指望你的应用进程会一直存活,以完成网络请求,处理响应并保存用户登录信息。更不用说用户的手机还有可能进入离线模式,失去网络连接。

从用户的角度来看,我已经输入了我的邮箱和密码,并且点击了登录按钮,因此我应该已经登录成功了。假设没有登录成功的话,用户会认为你的应用的用户体验很差,但事实上这并不是用户体验的问题,而是一个技术问题。

接下来你就会思考,ok,一旦我的应用即将退到后台,我就开启Service去执行登录操作,但是你不能!!!

这时候JobScheduler就能派上用场了。

ComponentName service = new ComponentName(this, MyJobService.class);
JobScheduler mJobScheduler = (JobScheduler)getSystemService(Context.JOB_SCHEDULER_SERVICE);
JobInfo.Builder builder = new JobInfo.Builder(jobId, serviceComponent)
 .setRequiredNetworkType(jobInfoNetworkType)
 .setRequiresCharging(false)
 .setRequiresDeviceIdle(false)
 .setExtras(extras).build();
mJobScheduler.schedule(jobInfo);

JobScheduler首先会调度一个任务,然后在合适的时机(比如说延迟若干时间之后,或者等手机空闲了)系统会开启你的MyJobService,然后执行onStartJob()里的处理逻辑。这个想法理论上很好,但是它只在API>21的系统上可用,而且在API 21&22的系统里JobScheduler还存在一个重大bug。

这意味着你只能在API>22的系统上使用JobScheduler。

如果你应用的minSDK < 23,你可以使用JobDispatcher

Job myJob = firebaseJobDispatcher.newJobBuilder()
 .setService(SmartService.class)
 .setTag(SmartService.LOCATION_SMART_JOB)
 .setReplaceCurrent(false)
 .setConstraints(ON_ANY_NETWORK)
 .build();
firebaseJobDispatcher.mustSchedule(myJob);

等等, 它需要使用 Google Play Services!!

因此如果你打算使用JobDispatcher,你将会抛弃数千万用户。

因此,JobDispatcher 可能不是一个好的选择。那么AlarmManager呢?通过AlarmManager去轮询检查网络请求是否执行成功,如果没有的话尝试再次执行它?

如果你还是想用Service来立即执行网络请求的话,可以选择JobIntentService

当SDK<26的时候,采用IntentService来执行任务;当SDK ≥ 26的时候,采用JobScheduler来执行任务。

Ahhhh… 它无法在 Android Oreo 上立即执行请求。

所以回到我们开始的地方:当应用退到后台的时候,依据Android系统的版本和手机的状态,选择合适的任务调度器来调度执行后台任务。

天呐,要做到既能节省手机电池的的电量又能为用户提供惊艳的用户体验实在是太难了吧!

3. WorkManager. Just because work should be easy to do.

依据手机所处的状态、Android系统版本、手机是否拥有Google Play Services,可以选择对应的解决方案。你可能会尝试着自己去实现这一整套复杂的处理逻辑。好消息是Android framework的设计者已经听到了我们的抱怨,他们决定去解决这个问题。

On the last Google I/O Android framework, the team announced WorkManager:

WorkManager aims to simplify the developer experience by providing a first-class API for system-driven background processing. It is intended for background jobs that should run even if the app is no longer in the foreground. Where possible, it uses JobScheduler or Firebase JobDispatcher to do the work; if your app is in the foreground, it will even try to do the work directly in your process.

哇! 这正是我们需要的!

WorkManager库包含以下几个组件:

WorkManager 接收带参数和约束条件的WorkRequest,并将其排入队列。

Worker 你只需要实现doWork() 这一个方法,它是执行在一个单独的后台线程里的。所有需要在后台执行的任务都在这个方法里完成。

WorkRequest 给Worker设置参数和约束条件(比如,是否联网、是否接通电源)等。

WorkResult Success, Failure, Retry.

Data 传递给Worker的持久化的键值对。

首先新建一个继承了Worker的类,并实现它的 doWork()方法:

public class LocationUploadWorker extends Worker {
    ...
     //Upload last passed location to the server
    public WorkerResult doWork() {
        ServerReport serverReport = new ServerReport(getInputData().getDouble(LOCATION_LONG, 0),
                getInputData().getDouble(LOCATION_LAT, 0), getInputData().getLong(LOCATION_TIME,
                0));
        FirebaseDatabase database = FirebaseDatabase.getInstance();
        DatabaseReference myRef =
                database.getReference("WorkerReport v" + android.os.Build.VERSION.SDK_INT);
        myRef.push().setValue(serverReport);
        return WorkerResult.SUCCESS;
    }
}

然后使用WorkManager将它排入任务队列:

Constraints constraints = new Constraints.Builder().setRequiredNetworkType(NetworkType
            .CONNECTED).build();
Data inputData = new Data.Builder()
            .putDouble(LocationUploadWorker.LOCATION_LAT, location.getLatitude())
            .putDouble(LocationUploadWorker.LOCATION_LONG, location.getLongitude())
            .putLong(LocationUploadWorker.LOCATION_TIME, location.getTime())
            .build();
OneTimeWorkRequest uploadWork = new OneTimeWorkRequest.Builder(LocationUploadWorker.class)
            .setConstraints(constraints).setInputData(inputData).build();
WorkManager.getInstance().enqueue(uploadWork);

接下来,WorkManager将会合理地调度执行你的任务;它会存储任务所有的参数,任务的细节,更新任务的状态。你甚至可以使用LiveData来订阅观察它的状态变化:

WorkManager.getInstance().getStatusById(locationWork.getId()).observe(this,
        workStatus -> {
    if(workStatus!=null && workStatus.getState().isFinished()){
         ...
    }
});

WorkManager库的架构图如下所示:

它能做的还不止这些。

你可以通过它来执行定时任务:

Constraints constraints = new Constraints.Builder().setRequiredNetworkType
        (NetworkType.CONNECTED).build();
PeriodicWorkRequest locationWork = new PeriodicWorkRequest.Builder(LocationWork
        .class, 15, TimeUnit.MINUTES).addTag(LocationWork.TAG)
        .setConstraints(constraints).build();
WorkManager.getInstance().enqueue(locationWork);

你也可以让多个任务按顺序执行:

WorkManager.getInstance(this)
        .beginWith(Work.from(LocationWork.class))
        .then(Work.from(LocationUploadWorker.class))
        .enqueue();

你还可以让多个任务同时执行:

WorkManager.getInstance(this).enqueue(Work.from(LocationWork.class, 
        LocationUploadWorker.class));

当然你也可以将以上三种任务执行方式结合起来使用。

注意: 你不能构建一个将定时任务和一次性任务混合在一起的任务链。

WorkManager可以做很多事情: 取消任务, 组合任务, 构建任务链, 将一个任务的参数合并到另一个任务。我建议你去查阅官方文档,里面有许多好的例子。

总结

为了遵循节省用户手机电池电量的原则,Android每一个版本都在不断改进,处理后台任务变得十分复杂。感谢Android团队,现在我们可以使用WorkManager来更加简单直接地处理处理后台任务。