29、WorkManager与传感器:定时采集数据的正确姿势

说实话,传感器数据采集这个需求,在Android开发里太常见了。你想想看,很多App都需要定期获取加速度、陀螺仪这些数据,然后上传分析。但问题来了——怎么采?什么时候采?采完怎么处理?

我见过不少新手直接开个Service,在里面写个死循环,或者用Handler发延迟消息。嗯,这些做法在早期确实能用,但放到现在,Android系统对后台的限制越来越严,动不动就被系统杀掉。我自己的项目里就踩过这个坑,后来才老老实实用上了WorkManager。

为什么是WorkManager?

WorkManager是Google官方推荐的异步任务库,专门用来处理那些「需要保证执行、但不一定立即执行」的任务。说白了,它帮你搞定了两件事:

  • 保证执行:就算App被杀死、手机重启,任务也会在条件满足时重新调度
  • 省电省资源:系统会把多个App的任务合并到一起执行,减少唤醒次数

对于传感器定时采集来说,这两点太关键了。你总不希望用户锁屏后,你的采集任务就断了吧?

核心思路:用WorkManager的PeriodicWorkRequest实现定时触发,在Worker里打开传感器、采集数据、然后关闭。每次采集是独立的,互不干扰。

先看一个最简单的例子

假设我们每15分钟采集一次加速度传感器数据,持续5秒钟。先定义一个Worker:

public class SensorWorker extends Worker {
    private static final String TAG = "SensorWorker";
    private static final long COLLECTION_DURATION_MS = 5000; // 采集5秒

    public SensorWorker(@NonNull Context context, @NonNull WorkerParameters params) {
        super(context, params);
    }

    @NonNull
    @Override
    public Result doWork() {
        // 这里不能直接操作UI,但可以访问传感器
        // 我会用一个CountDownLatch来同步等待采集完成
        final CountDownLatch latch = new CountDownLatch(1);
        final List<SensorEvent> collectedData = new ArrayList<>();

        SensorManager sensorManager = (SensorManager) getApplicationContext()
                .getSystemService(Context.SENSOR_SERVICE);
        Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);

        SensorEventListener listener = new SensorEventListener() {
            @Override
            public void onSensorChanged(SensorEvent event) {
                collectedData.add(event);
            }

            @Override
            public void onAccuracyChanged(Sensor sensor, int accuracy) {}
        };

        sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_NORMAL);

        try {
            latch.await(COLLECTION_DURATION_MS, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            sensorManager.unregisterListener(listener);
        }

        // 这里可以把collectedData存到数据库或上传
        saveData(collectedData);

        return Result.success();
    }

    private void saveData(List<SensorEvent> data) {
        // 实际项目中我会用Room或者直接写文件
        // 这里简单打印一下
        Log.d(TAG, "采集到 " + data.size() + " 条数据");
    }
}

个人习惯:我一般不在Worker里直接做网络请求,而是先把数据存到本地数据库,等下次App启动或者网络条件好时再上传。这样即使Worker执行过程中网络断了,数据也不会丢。

调度任务:PeriodicWorkRequest

Worker写好了,接下来就是调度它。注意,WorkManager的周期性任务最小间隔是15分钟,这是系统强制的,你设再小也没用。

PeriodicWorkRequest sensorWorkRequest =
        new PeriodicWorkRequest.Builder(SensorWorker.class, 15, TimeUnit.MINUTES)
                .setConstraints(new Constraints.Builder()
                        .setRequiresBatteryNotLow(true)  // 电量不要太低
                        .build())
                .addTag("sensor_collection")
                .build();

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "SensorCollectionWork",
        ExistingPeriodicWorkPolicy.KEEP,  // 如果已有任务,保留旧的
        sensorWorkRequest
);

这里我用了enqueueUniquePeriodicWork,而不是普通的enqueue。为什么?因为你不希望每次启动App都创建一个新的定时任务吧?用唯一名称可以保证整个App里只有一个采集任务在跑。

避坑指南:传感器在Worker里的注意事项

我曾经在Worker里直接注册传感器监听,结果发现数据采集到一半,Worker就被系统回收了。后来查文档才明白,Worker的doWork()方法是在后台线程执行的,而传感器回调也是异步的。如果你不阻塞住线程,Worker会立刻返回Result.success(),然后被销毁。

关键点:一定要用同步机制(CountDownLatch、回调转同步等)让Worker等待采集完成。否则你采集到的数据可能只有零星的几条,甚至一条都没有。

另外,别忘了在finally块里注销监听器。我见过有人忘记注销,导致传感器一直开着,电量哗哗地掉。用户发现后直接给了一星差评……嗯,这种低级错误咱们不能犯。

进阶:带条件的采集策略

实际项目中,你可能不想每次15分钟都采。比如用户正在走路时,数据变化大,可以采勤一点;用户静止时,数据变化小,可以跳过。我一般会在Worker里加一个「预检查」步骤:

@NonNull
@Override
public Result doWork() {
    // 预检查:如果上次采集的数据和这次差不多,就跳过
    if (shouldSkipCollection()) {
        Log.d(TAG, "数据变化不大,跳过本次采集");
        return Result.success();
    }

    // 正常采集流程...
    return doActualCollection();
}

private boolean shouldSkipCollection() {
    // 从数据库读取上次采集的均值,和当前传感器快速采样对比
    // 如果差值小于阈值,返回true
    // 具体实现略...
    return false;
}

这样做的好处很明显:省电、省存储、减少无用数据。你想想看,如果用户把手机放在桌上半小时,你每15分钟采一次,采到的全是几乎相同的值,有什么意义呢?

数据持久化:别让采集白费

Worker里采集到的数据,一定要及时存起来。我推荐用Room数据库,配合LiveData或Flow,这样UI层可以实时展示最新数据。

@Entity(tableName = "sensor_data")
public class SensorData {
    @PrimaryKey(autoGenerate = true)
    public long id;
    public long timestamp;
    public float x;
    public float y;
    public float z;
}

@Dao
public interface SensorDataDao {
    @Insert
    void insert(SensorData data);

    @Query("SELECT * FROM sensor_data ORDER BY timestamp DESC LIMIT 100")
    LiveData<List<SensorData>> getRecentData();
}

在Worker的saveData()方法里,把采集到的SensorEvent转换成SensorData对象,然后插入数据库。注意,数据库操作要在子线程执行,Room默认不允许在主线程操作数据库,而Worker本身就在后台线程,所以直接调用就行。

整体流程:一张图说清楚

下面这张图展示了WorkManager + 传感器采集的完整流程,从调度到数据落地:

WorkManager + 传感器采集流程图 应用启动 调度 PeriodicWorkRequest 间隔15分钟,带电量约束 Worker.doWork() 执行 后台线程,非UI线程 预检查 跳过采集 采集5秒数据 存入Room数据库 等待下一次调度...

总结一下

WorkManager + 传感器采集这个组合,说白了就是「系统帮你管调度,你只管采数据」。你不需要关心App是否在前台、手机是否锁屏,WorkManager会保证任务在合适的时候执行。

我个人习惯在项目里再加一层「采集策略管理器」,根据用户行为动态调整采集频率。比如用户正在运动时,把间隔从15分钟缩短到5分钟(虽然WorkManager最小15分钟,但你可以用多个OneTimeWorkRequest串联实现更短间隔)。不过那是进阶话题了,今天先把基础打牢。

嗯,最后提醒一句:别忘了在AndroidManifest里声明RECEIVE_BOOT_COMPLETED权限,否则手机重启后WorkManager不会重新调度任务。这个坑我踩过,希望你别再踩。