简介:这份资源面向需要在应用退居后台时仍能调用相机完成拍摄的 Android 开发者,聚焦实时监控、图像记录等场景下的实现思路。内容围绕 Service 后台运行、CAMERA 运行时权限申请、Camera 与 Camera2 两套 API 的取舍、Bitmap 压缩与图片处理、WakeLock 防止休眠中断、通知反馈及电池优化等关键环节展开,适合已具备一定 Android 基础、希望打通后台拍照链路的开发者参考。压缩包共 862 个文件,约 5.32MB,以 png 图片、xml 布局与配置、class 与 dex 编译产物为主,另含 java 源码、jar 依赖、aidl 接口定义及 gradle 构建脚本,并附带 apk 安装包,便于直接运行对照。目前已有 786 人学习下载,可帮助读者快速理解后台拍照的组件组织方式与工程结构,少走权限与生命周期方面的弯路。
1. 后台拍照这件事,先把边界说清楚
做过 Android 相机功能的开发者大概都遇到过这个需求:应用退到后台,或者屏幕熄灭,甚至用户正在用别的 App,这边还要悄悄拍一张照片。听起来简单,真动手就会发现坑一个接一个——Camera2 的会话在 Activity 一onPause就被系统回收,CameraX 的bindToLifecycle绑的是 UI 生命周期,前台服务在 Android 12 之后又多了启动限制。这个项目就是围绕「后台拍照」这个具体场景做的一套可运行实现,核心思路是把相机控制从 UI 生命周期里剥离出来,交给一个独立的前台服务持有,配合 CameraX 的ProcessCameraProvider做无界面预览采集。适合谁?适合正在做考勤打卡、巡检取证、行车记录、远程抓拍这类需要「无感触发拍照」的开发者,也适合想搞清楚 CameraX 生命周期解耦到底怎么做的同学。下面从目录结构一路拆到能改的代码。
2. 工程结构与技术选型:相机控制为什么不能挂在 Activity 上
2.1 目录分层与模块职责
这个项目的目录结构不复杂,但分层意图很明确:把「相机能力」和「触发入口」彻底分开,中间靠一个前台服务做桥。常见做法是下面这种布局,我按实际拆包习惯整理:
app/src/main/java/com/example/backgroundcamera/ ├── camera/ │ ├── CameraController.kt // 相机会话持有者,负责 bind/unbind │ ├── ImageCaptureUseCase.kt // 拍照用例封装,含保存回调 │ └── CameraConfig.kt // 分辨率、镜头方向、闪光灯等参数 ├── service/ │ ├── CaptureService.kt // 前台服务,持有 CameraController │ └── CaptureNotification.kt // 通知渠道与常驻通知构建 ├── storage/ │ ├── ImageSaver.kt // 文件命名、目录选择、MediaStore 写入 │ └── CaptureResult.kt // 拍照结果数据类 ├── ui/ │ ├── MainActivity.kt // 权限申请与手动触发入口 │ └── PreviewActivity.kt // 可选的前台预览页 └── util/ └── PermissionHelper.kt // 相机、存储、通知权限统一校验camera包只关心「怎么把一张照片拍出来」,不关心谁触发;service包负责生命周期和系统约束;storage包负责落盘。这种切法的好处是,后面要把拍照能力复用到别的入口(比如广播、定时任务、无障碍触发),只需要再写一个触发源,相机逻辑一行不用动。我一般会把CameraConfig单独拎出来,因为镜头方向、目标分辨率这些参数在不同业务里差异很大,集中管理比散落在各处强。
2.2 为什么选 CameraX 而不是裸 Camera2
技术选型上,这个项目用的是 CameraX。原因很实际:Camera2 的CameraDevice、CaptureSession状态机太啰嗦,一个onConfigured回调里套着createCaptureSession,再套createCaptureRequest,出错时日志能翻半天。CameraX 把「预览、拍照、分析」抽象成 UseCase,ProcessCameraProvider.bindToLifecycle一行就能把会话拉起来,ImageCapture.takePicture直接给回调。对于后台拍照这种「不需要复杂手动控制、但要稳定出图」的场景,CameraX 的收益远大于灵活性损失。
但 CameraX 有个默认行为必须改:bindToLifecycle的第一个参数是LifecycleOwner,如果你传 Activity,那 Activity 一停相机就解绑。后台拍照的关键就是把LifecycleOwner换成服务自己。常见做法是让CaptureService实现LifecycleOwner,内部持有一个LifecycleRegistry,在onCreate里handleLifecycleEvent(ON_START),在onDestroy里ON_DESTROY。这样相机会话的生命周期就跟着服务走,跟界面彻底脱钩。
class CaptureService : LifecycleService() { private lateinit var cameraProvider: ProcessCameraProvider private lateinit var imageCapture: ImageCapture override fun onCreate() { super.onCreate() startForeground(NOTIFICATION_ID, CaptureNotification.build(this)) // LifecycleService 自带 lifecycle,直接作为 bindToLifecycle 的 owner val future = ProcessCameraProvider.getInstance(this) future.addListener({ cameraProvider = future.get() bindCapture() }, ContextCompat.getMainExecutor(this)) } private fun bindCapture() { imageCapture = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setTargetRotation(Surface.ROTATION_0) .build() // 只绑 ImageCapture,不绑 Preview,后台无需渲染 cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, imageCapture) } }这段代码里几个点值得说清楚。LifecycleService是 androidx.lifecycle 提供的基类,它把Service和LifecycleOwner合到一起,省得自己写LifecycleRegistry。bindToLifecycle只传imageCapture不传Preview,是因为后台场景没有 Surface 可渲染,绑了 Preview 反而会因为找不到显示目标而报错。setCaptureMode选MINIMIZE_LATENCY是为了抓拍响应快,如果业务更看重画质可以换成MAXIMIZE_QUALITY,代价是快门延迟变长。setTargetRotation这里写死ROTATION_0,实际项目里要根据设备方向和镜头朝向动态算,否则竖屏拍出来可能是横的。
2.3 前台服务与通知渠道的硬约束
Android 8.0 之后,后台服务必须走startForegroundService并在 5 秒内startForeground,否则直接崩。Android 12 又加了前台服务启动限制,从后台启动前台服务会抛ForegroundServiceStartNotAllowedException。这个项目里的处理方式是:所有触发入口都保证在「用户可见的上下文」里发起服务启动,比如 Activity 的点击、通知的 PendingIntent、或者已经在前台的定时任务。如果确实需要从广播里拉起,得先判断ForegroundServiceStartNotAllowedException并降级。
fun startCapture(context: Context) { val intent = Intent(context, CaptureService::class.java) try { ContextCompat.startForegroundService(context, intent) } catch (e: ForegroundServiceStartNotAllowedException) { // 后台启动被拒,降级为记录待拍任务,等下次前台再执行 PendingCaptureQueue.enqueue(context) } }startForegroundService和startService的区别在于前者要求服务在限定时间内转前台,后者在 8.0 之后对后台场景基本不可用。ForegroundServiceStartNotAllowedException是 Android 12 引入的,捕获它之后不要静默吞掉,落一个待拍队列是更稳的做法。通知渠道的创建要在Application.onCreate里做,别等到服务里再建,否则第一次启动时通知可能因为渠道未就绪而显示异常。
3. 核心拍照链路:从触发到文件落盘的完整实现
3.1 ImageCapture 拍照与回调处理
相机会话绑好之后,拍照本身反而是最短的一段。ImageCapture.takePicture有两个重载:一个接收OnImageCapturedCallback在内存里拿ImageProxy,一个接收OutputFileOptions直接写文件。后台拍照建议用后者,因为内存里的ImageProxy需要手动close(),忘了就是内存泄漏,而且大图在内存里转一道再落盘,既慢又容易 OOM。
fun captureToFile(outputFile: File, onResult: (Boolean) -> Unit) { val options = ImageCapture.OutputFileOptions.Builder(outputFile) .setMetadata(ImageCapture.Metadata().apply { isReversedHorizontal = false // 前置镜头是否镜像,按业务定 }) .build() imageCapture.takePicture( options, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(output: ImageCapture.OutputFileResults) { onResult(true) } override fun onError(exception: ImageCaptureException) { // 常见:CAMERA_CLOSED、FILE_IO_ERROR、ENCODE_ERROR Log.e(TAG, "capture failed: ${exception.imageCaptureError}", exception) onResult(false) } } ) }OutputFileOptions.Builder接收一个File,CameraX 会自己完成编码和写入,回调里只告诉你成功还是失败。setMetadata里的isReversedHorizontal控制前置镜头是否做水平镜像,默认前置是镜像的,如果你要做人脸比对之类的场景,记得关掉。onError里的imageCaptureError是个枚举,CAMERA_CLOSED通常意味着会话被意外解绑(比如服务被杀),FILE_IO_ERROR多半是路径没权限或磁盘满,ENCODE_ERROR比较少见,一般是硬件编码器异常。把错误码打出来,排查时能省很多时间。
3.2 文件命名、目录与 MediaStore 写入
后台拍的照片,用户往往希望能在系统相册里看到,而不是埋在应用私有目录里。这就涉及 MediaStore 写入。Android 10 之后分区存储强制生效,直接往Environment.getExternalStoragePublicDirectory写文件会失败,正确姿势是通过MediaStore.Images插入一条记录拿到 Uri,再往这个 Uri 写流。但 CameraX 的OutputFileOptions只接受File,不接受Uri,所以常见做法是:先写到应用私有目录的临时文件,拍完再拷贝进 MediaStore,最后删临时文件。
fun saveToMediaStore(context: Context, tempFile: File): Uri? { val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "BG_${System.currentTimeMillis()}.jpg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/BackgroundCamera") } val resolver = context.contentResolver val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null resolver.openOutputStream(uri)?.use { out -> tempFile.inputStream().use { it.copyTo(out) } } tempFile.delete() return uri }RELATIVE_PATH指定相册里的子目录,Android 10 以下没有这个字段,需要走DATA列的老路子,项目里用Build.VERSION.SDK_INT做了分支。DISPLAY_NAME用时间戳保证唯一,避免同名覆盖。openOutputStream拿到的流写完要close,这里用use自动关。临时文件放在context.cacheDir下,即使拷贝失败也不会污染用户可见目录,下次启动可以清理残留。
3.3 无预览采集的取景与对焦补偿
后台拍照没有预览界面,意味着用户看不到取景框,也没法手动对焦。这带来两个实际问题:一是构图完全靠设备朝向,二是自动对焦可能对不上。CameraX 在没有 Preview 的情况下,ImageCapture依然会触发 3A(自动曝光、自动白平衡、自动对焦),但触发时机依赖takePicture调用那一刻的状态。如果刚绑完会话立刻拍,对焦可能还没收敛,出图是糊的。
常见补偿做法是:绑定会话后延迟一小段时间再拍,或者连续拍两张取第二张。更讲究一点的做法是绑一个ImageAnalysisUseCase,通过Camera2Interop拿到CaptureResult里的CONTROL_AF_STATE,等它变成PASSIVE_FOCUSED再触发拍照。这个项目里用的是简单版——服务启动后延迟 800ms 再执行拍照,实测在多数中端机上够用。如果你的业务对清晰度要求高,建议上ImageAnalysis方案,代价是多一路数据流,功耗和内存都会涨。
提示:延迟时间不要写死成全局常量,不同设备对焦速度差异很大,我一般会把它做成
CameraConfig里的可调参数,按机型或按业务场景覆盖。
4. 权限、兼容与避坑:后台拍照最容易翻车的几个点
4.1 权限申请顺序与运行时校验
后台拍照涉及的权限不止一个:CAMERA、POST_NOTIFICATIONS(Android 13+)、以及写相册需要的READ_MEDIA_IMAGES(Android 13+)或WRITE_EXTERNAL_STORAGE(Android 10 以下)。顺序上,先申请相机和通知,再申请存储,因为存储权限在分区存储下其实只在读旧图时需要,写自己的图走 MediaStore 不需要额外权限。PermissionHelper里把每组权限的「是否已授予」「是否应展示 rationale」「是否永久拒绝」三个状态都封装了,避免在 Activity 里写一堆shouldShowRequestPermissionRationale判断。
fun ensureCameraPermission(activity: Activity, requestCode: Int): Boolean { return if (ContextCompat.checkSelfPermission( activity, Manifest.permission.CAMERA ) == PackageManager.PERMISSION_GRANTED ) { true } else { ActivityCompat.requestPermissions( activity, arrayOf(Manifest.permission.CAMERA), requestCode ) false } }这段是标准写法,但要注意:如果用户勾了「不再询问」,requestPermissions会立刻回调拒绝且不再弹框,此时应该引导去设置页。项目里在onRequestPermissionsResult里判断shouldShowRequestPermissionRationale为 false 且未授予,就弹一个跳设置的对话框。这个细节不做,用户会以为功能坏了。
4.2 避坑清单:五个真实踩过的坑
现象一:服务启动后相机黑屏或直接报CAMERA_IN_USE。原因是有另一个应用(或本应用的前台预览页)占着相机没释放。解决:在bindToLifecycle之前先cameraProvider.unbindAll(),并在服务onDestroy里也调一次;同时检查是否有残留的PreviewActivity没 finish。
现象二:Android 12 上从广播触发拍照,服务起不来。原因是前台服务后台启动限制。解决:改用PendingIntent从通知触发,或者把触发时机挪到应用处于前台时;捕获ForegroundServiceStartNotAllowedException做降级队列。
现象三:拍出来的照片方向不对,竖屏设备出横图。原因是setTargetRotation写死了。解决:用display.rotation或WindowManager.defaultDisplay.rotation动态设置,并在保存时把Metadata.rotation一起写进 EXIF。
现象四:连续快速触发拍照,第二张报IMAGE_CAPTURE_FAILED。原因是ImageCapture上一次拍照还没完成就发起下一次。解决:加一个AtomicBoolean或Mutex做串行化,拍照期间忽略新请求或排队。
现象五:照片在相册里能看到,但打开是 0 字节。原因是 MediaStore 插入后写流失败但没检查返回值。解决:openOutputStream返回 null 时回滚删除该 Uri,并记录日志;写完flush再关流。
4.3 电量与温控的现实约束
后台常驻相机对电量消耗不小,尤其是持续绑着会话但长时间不拍的情况。CameraX 在无 Preview 时功耗比有预览低,但传感器和 ISP 仍在工作。我一般会在服务里加一个空闲超时:超过 N 分钟没有拍照请求就unbindAll并stopSelf,下次触发再重新拉起。温控方面,部分设备在相机长时间开启后会触发降频,表现为拍照延迟从几百毫秒涨到两三秒,这时候MINIMIZE_LATENCY也救不了,只能靠业务层做超时兜底。
注意:不要为了「随时能拍」就让服务永久前台常驻,系统对长时间前台服务的电量和通知管理越来越严,用户也容易反感。按需拉起、拍完即走是更可持续的做法。
5. 二次开发改造:把拍照能力接到你自己的业务上
5.1 扩展触发源与结果回调
这个项目默认的触发入口是MainActivity里的按钮,但真实业务里触发源五花八门:定时任务、地理围栏、蓝牙设备事件、甚至无障碍服务监听到的界面变化。改造的关键是把CaptureService的启动参数化,用Intentextra 传业务标识,拍照完成后通过LocalBroadcast或ResultReceiver把结果回传给触发方。
// 触发方 val intent = Intent(context, CaptureService::class.java).apply { putExtra("biz_id", "inspection_001") putExtra("save_to_gallery", true) } ContextCompat.startForegroundService(context, intent) // 服务内拍照完成后 val result = Bundle().apply { putString("biz_id", bizId) putString("image_uri", uri.toString()) } resultReceiver?.send(RESULT_OK, result)biz_id用来让回调方知道这次拍照属于哪个业务,避免多个触发源并发时结果串台。ResultReceiver适合跨进程或跨组件回传,如果触发方和服务在同一进程,用LocalBroadcastManager或Flow更轻。save_to_gallery控制是否写 MediaStore,有些业务(比如内部巡检)只需要私有目录留档,不需要进相册,这个开关能省掉一次拷贝。
5.2 参数化配置与多镜头适配
CameraConfig建议做成可外部注入的,而不是硬编码在服务里。常见做法是通过Intentextra 或SharedPreferences传入镜头选择(前/后/外接)、目标分辨率、JPEG 质量、是否镜像。多镜头适配的坑在于CameraSelector.DEFAULT_BACK_CAMERA不一定存在(比如某些平板只有前摄),需要先cameraProvider.hasCamera(selector)判断,再降级到DEFAULT_FRONT_CAMERA或DEFAULT_EXTERNAL_CAMERA。
| 配置项 | 默认值 | 可调范围 | 影响 |
|---|---|---|---|
| 镜头选择 | 后置 | 前置/后置/外接 | 决定CameraSelector |
| 拍照模式 | MINIMIZE_LATENCY | 画质优先 | 快门延迟与出图质量 |
| 目标旋转 | 动态计算 | 0/90/180/270 | 出图方向 |
| JPEG 质量 | 95 | 60~100 | 文件大小与清晰度 |
| 空闲超时 | 5 分钟 | 1~30 分钟 | 功耗与响应速度 |
这张表是我在实际项目里反复调过的几个参数,JPEG 质量从 95 降到 85 文件能小一半,肉眼几乎看不出差别,对存储压力大的场景很值。空闲超时设太短会导致频繁重绑会话,设太长又费电,5 分钟是个折中。
5.3 验证方法与我的习惯
改完之后怎么验证?我一般分三层:第一层是单元测试,把ImageSaver的命名和路径逻辑抽出来测,不依赖真机;第二层是仪器测试,用CameraX的FakeCamera或直接跑真机,重点测「服务被杀后重启能否恢复」「连续拍 10 张是否稳定」;第三层是场景测试,锁屏、切后台、低电量模式下各拍一次,看通知是否正常、文件是否完整。血泪经验是:别只在模拟器上测,模拟器的相机是虚拟的,对焦和方向行为跟真机差很远,很多坑只有真机才暴露。
从那以后我每次改相机相关代码,都强制走一遍「锁屏拍、后台拍、连续拍、杀进程后拍」这四个场景,少一个都不敢提测。希望帮到你。
本文还有配套的精品资源,点击获取