简介:这份资源面向需要在应用退居后台时仍能调用相机完成拍摄的 Android 开发者,聚焦实时监控、图像记录等场景下的实现思路。内容围绕 Service 后台运行、CAMERA 运行时权限申请、Camera 与 Camera2 两套 API 的取舍、Bitmap 压缩与图片处理、WakeLock 防止休眠中断、通知反馈及电池优化等关键环节展开,适合已具备 Android 基础、希望打通后台拍照链路的进阶学习者参考。压缩包共 862 个文件,约 5.32MB,以 png 图片、xml 布局与配置、class 与 java 源码、jar 依赖为主,另含 aidl、gradle 构建脚本及 apk 产物,便于直接导入工程对照调试。目前已有 786 人学习下载,可借助完整工程结构与源码理解服务声明、权限请求和相机调用的组织方式,减少自行摸索的成本。
1. 后台拍照这件事,先把边界说清楚
去年做一个人脸打卡的模块,产品经理的原话是「用户点一下打卡按钮,App 退到后台也能把照片传上来」。听起来简单,真动手才发现坑在系统层:Android 从 6.0 开始收紧权限,从 8.0 开始限制后台服务,从 10 开始连后台启动 Activity 都给你拦了。所谓「后台拍照」,本质不是让相机在后台偷偷工作,而是让拍照这个动作在用户无感知或弱感知的情况下完成,同时不违反系统的前台服务约束。这个项目就是围绕这个诉求做的一套可运行 Demo:用 Camera2 拿到预览与图像数据,用前台服务承载生命周期,用文件落盘和回调把结果交回业务层。适合正在做打卡、巡检、证件采集这类需要「点了就走」的开发者,也适合想搞懂 Camera2 会话机制的人拿来当脚手架。
2. 技术选型与工程结构:为什么是 Camera2 加前台服务
2.1 相机 API 的取舍
项目没有用已废弃的android.hardware.Camera,也没有直接上 CameraX。原因很实际:CameraX 封装度高,但后台场景下对ImageAnalysis和ImageCapture的调度控制不够细,尤其是需要在没有预览界面的情况下拿到一帧 JPEG 时,CameraX 的用例绑定会强制要求一个PreviewView或至少一个 Surface。Camera2 虽然啰嗦,但CameraCaptureSession允许只绑定ImageReader的 Surface,不依赖任何可见 View,这正是后台拍照需要的。
代价是要自己处理状态机。Camera2 的打开、配置、会话创建、请求下发是异步回调链,任何一步顺序错了就是黑屏或者CameraAccessException。项目里用了一个CameraSessionManager把这条链收敛成「打开 → 配置 → 建会话 → 拍照 → 关闭」五个状态,业务层只调一个capture()。
2.2 目录结构
工程是标准的单模块 Android 项目,包结构按职责切分,没有过度分层:
app/src/main/java/com/example/backgroundcamera/ ├── camera/ │ ├── CameraSessionManager.kt // Camera2 状态机与回调 │ ├── ImageSaver.kt // JPEG 落盘与 EXIF 写入 │ └── CaptureRequestBuilder.kt // 请求参数组装 ├── service/ │ ├── CaptureForegroundService.kt // 前台服务,承载拍照生命周期 │ └── CaptureNotification.kt // 通知渠道与常驻通知 ├── ui/ │ ├── MainActivity.kt // 触发入口与结果展示 │ └── PermissionGate.kt // 权限申请封装 ├── data/ │ └── CaptureRepository.kt // 结果回调与文件路径管理 └── util/ ├── FileProviderHelper.kt // 跨进程文件共享 └── Threads.kt // 相机线程与 IO 线程camera包只关心「怎么拍到一张图」,service包只关心「怎么让拍照在后台活下来」,data包负责把结果交出去。这种切法的好处是:换相机实现(比如以后迁 CameraX)只动camera包,换调度方式(比如改成 WorkManager 触发)只动service包。
2.3 前台服务为什么绕不开
Android 8.0 之后,后台应用不能随意启动服务,必须用startForegroundService()并在 5 秒内调用startForeground()挂一个常驻通知。Android 10 之后,后台启动 Activity 被默认禁止,所以「拍完弹个预览页」这种设计在后台场景下直接失效,只能走通知或者回调到已存在的界面。
项目的做法是:拍照全程在一个ForegroundService里跑,通知栏显示「正在采集图像」,拍完更新通知为「采集完成」,点击通知回到MainActivity看结果。这样既满足系统约束,又给了用户一个可感知的出口,不至于变成「偷偷拍照」的黑匣子。
// CaptureForegroundService.kt 关键片段 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // 必须在 5 秒内挂上前台通知,否则系统抛 ANR 级异常 startForeground(NOTIFICATION_ID, CaptureNotification.build(this, "正在采集图像")) val outputFile = File(cacheDir, "capture_${System.currentTimeMillis()}.jpg") sessionManager.capture(outputFile) { success -> // 回调在主线程之外,更新通知要切回主线程 mainHandler.post { CaptureNotification.update(this, if (success) "采集完成" else "采集失败") stopSelf(startId) // 任务结束主动停服务,避免常驻耗电 } } return START_NOT_STICKY // 被杀后不自动重启,避免重复采集 }这段代码里三个参数值得说:NOTIFICATION_ID要固定,否则每次startForeground都会新建一条通知;START_NOT_STICKY是刻意选的,后台拍照属于一次性任务,系统杀掉后重启反而会造成重复采集;stopSelf(startId)带上 startId 是为了只停掉本次启动,不影响可能存在的其他任务。通知渠道在CaptureNotification里用NotificationChannel建,重要性设为IMPORTANCE_LOW,避免每次拍照都响一声。
3. Camera2 会话链路:从打开相机到拿到 JPEG
3.1 打开相机与选择镜头
CameraManager.openCamera()是整条链的起点,它返回的是异步回调,onOpened里才能拿到CameraDevice。项目里封装成挂起函数风格,用CountDownLatch或者suspendCancellableCoroutine都行,这里用后者更符合 Kotlin 习惯。
// CameraSessionManager.kt private suspend fun openCamera(cameraId: String): CameraDevice = suspendCancellableCoroutine { cont -> cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { override fun onOpened(device: CameraDevice) { if (cont.isActive) cont.resume(device) } override fun onDisconnected(device: CameraDevice) { device.close() if (cont.isActive) cont.resumeWithException(IllegalStateException("相机被占用")) } override fun onError(device: CameraDevice, error: Int) { device.close() if (cont.isActive) cont.resumeWithException(CameraAccessException(error)) } }, cameraHandler) }cameraHandler绑定在一条独立的HandlerThread上,不能是主线程。这是血泪经验:相机回调里做任何耗时操作都会拖慢帧率,主线程上更是直接卡 UI。onDisconnected通常意味着另一个应用抢占了相机,比如用户切到系统相机,这时候必须close()并让业务层知道,否则后续请求全部失败。
镜头选择上,项目默认取后置主摄:遍历cameraManager.cameraIdList,用CameraCharacteristics.LENS_FACING过滤LENS_FACING_BACK,再按SENSOR_INFO_PIXEL_ARRAY_SIZE排序取最大。前置摄像头在后台场景下容易触发镜像和旋转问题,除非业务明确要自拍,否则不建议默认用。
3.2 配置输出 Surface 与创建会话
后台拍照不需要预览,所以ImageReader是唯一的输出目标。ImageReader.newInstance(width, height, ImageFormat.JPEG, maxImages)里maxImages设 2 就够,设大了占内存,设 1 在某些机型上会因为缓冲区未释放导致丢帧。
// 创建 ImageReader 并绑定到会话 val imageReader = ImageReader.newInstance( targetWidth, targetHeight, ImageFormat.JPEG, 2 ) imageReader.setOnImageAvailableListener({ reader -> val image = reader.acquireNextImage() ?: return@setOnImageAvailableListener try { // JPEG 格式下 plane[0] 就是完整压缩数据 val buffer = image.planes[0].buffer val bytes = ByteArray(buffer.remaining()).also { buffer.get(it) } imageSaver.save(bytes, outputFile) onResult(true) } catch (e: Exception) { onResult(false) } finally { image.close() // 必须关闭,否则 maxImages 耗尽后不再回调 } }, ioHandler) val surface = imageReader.surface val request = device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(surface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) set(CaptureRequest.JPEG_ORIENTATION, computeOrientation()) } device.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { session.capture(request.build(), null, cameraHandler) } override fun onConfigureFailed(session: CameraCaptureSession) { onResult(false) } }, cameraHandler)几个参数要盯住:TEMPLATE_STILL_CAPTURE是拍照模板,会自动带上合适的曝光和对焦策略;CONTROL_AF_MODE_CONTINUOUS_PICTURE让对焦持续工作,避免拍出糊图,但后台场景下如果镜头被遮挡,对焦会反复拉风箱,可以退化成CONTROL_AF_MODE_AUTO只对一次;JPEG_ORIENTATION必须根据设备旋转和镜头方向算,算错了照片就是横的,这是最常见的翻车点。
computeOrientation()的逻辑是:读SensorOrientation,结合Display.rotation和前后置做加减。后置一般是(sensorOrientation + deviceRotation * 90) % 360,前置还要额外镜像。项目里把这段单独放在CaptureRequestBuilder,方便单测。
3.3 分辨率与画质权衡
targetWidth/targetHeight不是随便填的。直接从StreamConfigurationMap.getOutputSizes(ImageFormat.JPEG)里取最大尺寸,在低端机上会分配巨大缓冲区,拍照瞬间内存飙升甚至 OOM。项目的做法是取「不超过 1920×1080 的最大可用尺寸」,兼顾清晰度和内存。
val sizes = characteristics.get( CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP )?.getOutputSizes(ImageFormat.JPEG) ?: emptyArray() val target = sizes.filter { it.width <= 1920 && it.height <= 1080 } .maxByOrNull { it.width * it.height } ?: sizes.minByOrNull { it.width * it.height }!!这段逻辑先过滤再取最大,兜底取最小,保证任何机型都能拿到一个合法尺寸。注意getOutputSizes返回的尺寸列表在不同厂商 ROM 上差异很大,有的机型会返回一些非标准比例,业务层如果要做裁剪,最好在拿到图之后再处理,不要在请求阶段假设比例。
4. 权限、生命周期与避坑排查
4.1 权限申请的正确姿势
后台拍照涉及三个权限:CAMERA、FOREGROUND_SERVICE,Android 13 之后还要POST_NOTIFICATIONS(否则前台服务通知不显示,用户看不到采集状态)。CAMERA是运行时权限,必须在触发拍照前拿到,不能在服务里临时申请——服务里没有 Activity 上下文,申请了也没法弹框。
PermissionGate里用registerForActivityResult(RequestPermission())封装,申请前先checkSelfPermission,被拒绝后要区分「首次拒绝」和「勾选不再询问」,后者只能引导去设置页。这里有个细节:Android 11 之后shouldShowRequestPermissionRationale在「不再询问」时返回 false,和「首次未申请」一样,所以要用SharedPreferences记一个「已申请过」的标记来区分。
4.2 生命周期与相机释放
相机是独占资源,onPause不释放,切到别的应用再回来大概率onDisconnected。项目的策略是:MainActivity的onStop里通知服务停止采集并释放相机,onStart里重新初始化。服务本身不持有 Activity 引用,通过CaptureRepository的单例回调把结果传回,避免内存泄漏。
// MainActivity.kt override fun onStop() { super.onStop() // 界面不可见时主动收尾,避免相机被系统回收后状态错乱 if (isFinishing.not() && repository.isCapturing()) { repository.cancelCapture() } }cancelCapture()内部会session.close()、device.close()、imageReader.close(),顺序不能反:先关会话再关设备,最后关 Reader,反了会抛IllegalStateException。
4.3 避坑排查:五条踩过的坑
现象一:拍照回调一直不触发。原因多半是ImageReader的maxImages被占满没释放,或者setOnImageAvailableListener的 Handler 线程已退出。解决:确保每次acquireNextImage后image.close(),Handler 用存活的HandlerThread,服务销毁时quitSafely()。
现象二:照片方向不对,人像横躺。原因是JPEG_ORIENTATION没算或算错。解决:按SensorOrientation加设备旋转计算,前置额外加镜像标志,拍完用 EXIF 校验一次。
现象三:Android 12 以上服务启动即崩。报ForegroundServiceStartNotAllowedException。原因是应用在后台时调了startForegroundService。解决:从可见界面触发,或者用WorkManager加setExpedited走豁免路径,别在BroadcastReceiver里直接起。
现象四:部分机型拍出来全黑。原因是会话创建时只绑了ImageReader的 Surface,某些 ROM 要求至少有一个预览 Surface 才肯出图。解决:加一个 1×1 的SurfaceTexture作为占位预览目标,不显示但满足驱动要求。
现象五:连续拍照第二次失败。原因是第一次的CaptureSession没关就建新的。解决:每次拍照完session.close(),或者复用同一个会话连续下发capture请求,项目选的是后者,性能更好。
提示:调试相机问题时,
dumpsys media.camera能看到当前相机被谁占用,比盲猜快得多。
5. 二次开发:把 Demo 改成能进生产的采集模块
拿到这个工程后,最常改的三个方向是:换触发方式、加图像处理、接业务回调。换触发方式最典型的是从「点按钮」改成「定时或事件驱动」,这时候把MainActivity里的启动逻辑抽到CaptureRepository.trigger(),由WorkManager或BroadcastReceiver调用即可,服务本身不用动。
加图像处理的话,在ImageSaver.save()之前插一步。JPEG 字节流可以直接交给BitmapFactory.decodeByteArray,做压缩、加水印、人脸检测都行。但要注意别在相机回调线程里做重活,项目里ImageSaver跑在独立的 IO 线程,处理完再回调。
// ImageSaver.kt 扩展点 fun save(bytes: ByteArray, outputFile: File, processor: (ByteArray) -> ByteArray = { it }) { val processed = processor(bytes) // 默认恒等,业务可注入压缩/水印 outputFile.outputStream().use { it.write(processed) } writeExif(outputFile, orientation) // EXIF 单独写,避免处理破坏元数据 }这个processor参数就是留给二次开发的钩子,默认不处理,需要时传一个 lambda 进去。writeExif单独抽出来是因为很多图像处理库会丢掉 EXIF,方向信息一丢,前面算的JPEG_ORIENTATION就白费了。
验证改造是否成功,我一般跑三步:先在模拟器上确认权限流程和通知展示,再用两台中端真机(一高一低)测连续拍 10 张的稳定性,最后用adb shell dumpsys meminfo看拍照前后内存有没有持续上涨。相机模块的内存泄漏很隐蔽,拍几十张才崩,早测早省事。
有个习惯我保持了几年:任何涉及相机的改动,都在onImageAvailable里加一行日志打印image.width/height/format,跑一遍再删。因为相机问题十有八九是「你以为的尺寸和实际拿到的尺寸不一致」,这行日志比任何断点都直接。希望这套结构能帮你少走几段弯路。
本文还有配套的精品资源,点击获取