
经常有人在技术群里甩出两张对比图同一颗传感器、同一个场景自己写的相机 App 拍出来发灰、噪点多、边缘糊成一团而系统自带的相机却通透锐利。更让人困惑的是代码里明明已经把分辨率拉到最高、JPEG 质量设到 100画质还是差一大截。这个落差不是你的代码写得烂而是 Android 相机这条链路上有一大段能力是系统相机独享的。这篇内容就围绕android 原生相机 apk和原生模式相机 APP这两件事展开把 Camera2 的会话模型、手动模式的参数联动、RAW 输出的落盘方式、真机上必然撞见的几个坑以及怎么把自己做的相机 App 打包成一个能装能用的 APK全部拆开讲清楚。适合已经会写 Activity、想真正搞懂相机管线的中级开发者也适合只想做一个调用系统相机外壳的轻量需求方——两条路线我都会给出取舍依据。1. 自己写的相机 App 为什么拍不出系统相机的味道1.1 一条被厂商截断的处理链路先把结论摆在前面第三方 App 拿到的图像通常不是传感器最原始的那份数据。手机成像的完整链路大致是—— sensor 出 Bayer 数据 → ISP 做黑电平校正、去马赛克、白平衡、降噪、锐化、色调映射 → 多帧合成 → 编码输出。系统相机走的是厂商深度定制的私有通路可以拿到多帧 RAW、可以调用独立的 NPU 做语义分割、可以拿到 10bit HDR 的中间结果而第三方 App 通过 Camera2 拿到的绝大多数情况是 ISP 已经处理完的 YUV 或 JPEG你能做的后处理空间非常有限。这就是为什么你在 App 里调高饱和度、加锐化出来的效果总有一种塑料感——你在给一份已经被压扁的动态范围做二次加工暗部信息早就被 ISP 的色调曲线切掉了再拉也拉不回来。我在一个图像处理 Demo 里做过对比同一场景下系统相机出的 JPEG 直方图两端都有细节而自己 App 拿到的 YUV 转出来的图高光区域像素值大量贴在 235 附近等于已经削顶。还有一层更隐蔽的限制部分厂商会在 HAL 层对第三方应用做降级策略比如限制同时输出的流数量、限制可用的高帧率档位、限制多摄切换广角/长焦的物理镜头往往只对系统相机开放逻辑切换。这些限制不会报错只是默默地不给你。你能做的第一件事是先用CameraCharacteristics把设备能力摸清楚别在 LEGACY 级别的设备上死磕手动模式。1.2 两种原生拉起系统相机 vs 直连 Camera2原生模式相机 APP这个说法在实际项目里其实指两种完全不同的东西选错了方向会白写几千行代码。第一种是外壳型App 本身不做预览和成像用MediaStore.ACTION_IMAGE_CAPTURE或ACTION_VIDEO_CAPTURE把系统相机拉起来拍完拿回结果。优点是零成本继承厂商全部算法画质和系统相机一模一样缺点是 UI 完全不可控、无法做连拍、无法做自定义叠加层、部分机型返回的缩略图分辨率很低需要额外传EXTRA_OUTPUT指定文件路径才能拿到原图。第二种是直连型自己用 Camera2 或 CameraX 搭建预览和采集管线UI 完全自绘可以叠加网格、水平仪、直方图、手动参数面板。这就是真正意义上的原生模式相机代价是要自己处理旋转、尺寸协商、生命周期、性能。对比项外壳型Intent 拉起直连型Camera2/CameraX画质上限等于系统相机受限于厂商对第三方的开放程度UI 自由度几乎为零完全可控连拍/定时/手动参数不支持全部可做开发成本半天一周起步适合场景表单附件、证件翻拍、快速上传摄影工具、行业采集、需要叠加信息的场景主要风险各机型返回结果不一致设备兼容性与性能调优我的建议很直接如果你的需求只是让用户拍张照传上来别自己写相机用 Intent 或者系统的照片选择器省下来的时间够你把上传链路优化三轮。只有当业务真的需要自定义取景框、需要连续采集、需要在预览上叠加测量标记时直连 Camera2 才有意义。2. Camera2 的会话模型一帧画面是怎么走完全程的2.1 CameraManager 与设备能力画像Camera2 的入口是CameraManager通过getCameraIdList()拿到所有逻辑相机 ID。注意这里说的是逻辑相机——一台多摄手机可能物理上有四颗镜头但对外只暴露两三个 ID剩下的通过LOGICAL_MULTI_CAMERA能力在内部切换。想知道某颗镜头到底支不支持广角得读CameraCharacteristics里的CONTROL_ZOOM_RATIO_RANGE或LENS_INFO_AVAILABLE_FOCAL_LENGTHS。打开设备前我习惯先做一次体检把关键能力打印出来这一步能省掉后面无数次的玄学调试val manager getSystemService(Context.CAMERA_SERVICE) as CameraManager manager.cameraIdList.forEach { id - val c manager.getCameraCharacteristics(id) val level c.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) val caps c.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: intArrayOf() val facing c.get(CameraCharacteristics.LENS_FACING) Log.d(CamProbe, id$id facing$facing level$level caps${caps.joinToString()}) c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.let { map - map.getOutputSizes(ImageFormat.JPEG)?.forEach { Log.d(CamProbe, jpeg ${it.width}x${it.height}) } } }INFO_SUPPORTED_HARDWARE_LEVEL是判断设备档位最直接的依据它的四个档位决定了你能干什么等级含义能做的事LEGACY仅等价于旧的 Camera1 能力基础预览与拍照无手动参数LIMITED支持部分 Camera2 特性支持 30fps 的 YUV 输出、基础 3A 控制FULL完整支持 Camera2 核心能力手动传感器、手动后处理、RAW 输出LEVEL_3在 FULL 基础上再增强支持重处理reprocess、更高的输出配置自由度真机上的现实是不少中低端机型仍然是 LIMITED这时候你在代码里设置CONTROL_AE_MODE_OFF是无效的不会崩但参数被静默忽略画面依旧自动曝光。很多人调试手动模式调到怀疑人生最后发现是设备压根不支持——先查REQUEST_AVAILABLE_CAPABILITIES里有没有MANUAL_SENSOR比什么都快。2.2 CaptureSession 是一份带宽合同Camera2 最容易让人栽跟头的地方是把createCaptureSession当成一个普通的初始化调用。它其实是一次资源协商你告诉系统我要同时往这几个 Surface 上出图系统在硬件带宽允许的范围内给你一份合同签完就不能随便改。一份典型的会话配置长这样预览用SurfaceTexture走 GPU 渲染、拍照用ImageReader(ImageFormat.JPEG)、可选的分析流用ImageReader(ImageFormat.YUV_420_888)。三路同时开在中端机上是会失败的因为 ISP 的输出带宽有限。失败的表现不一定是抛异常可能是回调里直接给你onConfigureFailed也可能是配置成功但帧率掉到个位数。所以工程上必须有降级策略我通常按这个顺序尝试预览 JPEG YUV 三路如果设备支持YUV_REPROCESSING且等级为 FULL预览 JPEG 两路预览 JPEG且把预览分辨率降到 1280×720配置会话的代码骨架val previewSurface surfaceTexture.let { Surface(it) } val jpegReader ImageReader.newInstance(jpegW, jpegH, ImageFormat.JPEG, 3) val surfaces listOf(previewSurface, jpegReader.surface) device.createCaptureSession(surfaces, object : CameraCaptureSession.StateCallback() { override fun onConfigured(session: CameraCaptureSession) { thisNativeCamera.captureSession session val req device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(previewSurface) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) } previewRequestBuilder req session.setRepeatingRequest(req.build(), null, cameraHandler) } override fun onConfigureFailed(session: CameraCaptureSession) { Log.e(Cam, configure failed, fallback needed) // 触发降级流程 } }, cameraHandler)注意ImageReader的 maxImages 不要开太大3 张足够开 8 张在某些机型上会直接抢不到缓冲区导致拍照超时。这个细节文档里不会强调但真机上踩过就知道。还有一个常被忽略的点会话必须在正确的线程上创建。我见过有人把cameraHandler传成主线程的 Handler结果预览偶尔卡顿一两百毫秒追了半天才发现是主线程在做 Surface 配置。给相机单独开一个HandlerThread是基本操作。3. 动手做一个能跑的原生模式相机 APK3.1 工程骨架与权限声明从零搭一个最小可用的相机 App模块结构其实很简单一个 Activity 负责 UI 与生命周期一个CameraController封装会话逻辑一个SurfaceView或TextureView承载预览。新手容易在 View 选型上纠结我给一个直接的判断标准SurfaceView独立绘图层性能更好、功耗更低但做圆角、透明叠加、动画变换会很别扭因为它在 View 层级之外。TextureView作为普通 View 参与合成支持变换矩阵、动画、圆角代价是多一次 GPU 拷贝功耗略高。如果只是标准全屏预览用 SurfaceView如果预览区域要做圆角卡片、要跟其他 View 做转场动画用 TextureView。权限这块AndroidManifest.xml里至少要有uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-feature android:nameandroid.hardware.camera.any android:requiredtrue / uses-feature android:nameandroid.hardware.camera.autofocus android:requiredfalse /uses-feature这两行很多人不写导致应用在应用市场里被推给没有摄像头的设备安装后一打开就崩。android.hardware.camera.any声明成requiredtrue之后没有摄像头的设备根本装不上从源头避免问题。运行时权限要单独处理并且要考虑用户点了拒绝且不再询问的情况——这时候你得引导他去设置页而不是一直弹窗。我的做法是权限被永久拒绝后把预览区域替换成一个带说明文字的占位视图中间放一个去设置按钮跳Settings.ACTION_APPLICATION_DETAILS_SETTINGS。3.2 预览尺寸协商与旋转矩阵尺寸协商是相机开发里最不起眼但最容易翻车的一环。系统的SCALER_STREAM_CONFIGURATION_MAP会告诉你所有可用尺寸但你不能随便挑一个——挑错了就是画面拉伸、裁切或者干脆黑屏。核心原则有三条预览尺寸的宽高比要贴近预览控件的宽高比预览尺寸不要超过屏幕分辨率太多1080p 足够4K 预览纯属浪费带宽拍照尺寸和预览尺寸可以不同但同一个 Surface 上的比例必须一致。选尺寸的实用函数private fun chooseSize(sizes: ArraySize, viewW: Int, viewH: Int): Size { val targetRatio viewW.toFloat() / viewH val candidates sizes.filter { it.width 1920 it.height 1080 } .ifEmpty { sizes.toList() } return candidates.minByOrNull { val r it.width.toFloat() / it.height abs(r - targetRatio) * 1000 abs(it.width - 1920) * 0.01f } ?: sizes.first() }这里的权重设计值得说一下比例偏差我给了一个很大的系数1000意味着比例正确的优先级远高于分辨率高。因为比例不对带来的拉伸用户一眼就能看出来而 1280×720 和 1920×1080 的差别在小屏预览上几乎感知不到。旋转处理是第二个大坑。传感器的安装方向SENSOR_ORIENTATION和设备当前的显示旋转叠加才是最终需要旋转的角度。公式是val sensorOrientation characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 val deviceDegrees when (windowManager.defaultDisplay.rotation) { Surface.ROTATION_0 - 0 Surface.ROTATION_90 - 90 Surface.ROTATION_180 - 180 else - 270 } val result if (facing CameraCharacteristics.LENS_FACING_FRONT) { (sensorOrientation deviceDegrees) % 360 } else { (sensorOrientation - deviceDegrees 360) % 360 }前置摄像头要做加法因为预览是镜像的后置做减法。这个正负号写错横屏时后置摄像头拍出来的照片就会倒 180 度而且只在部分机型上出现极其难查。拿到这个角度之后预览侧用TextureView.setTransform(Matrix)做一次旋转加缩放拍照侧则把它塞进CaptureRequest.JPEG_ORIENTATION让 JPEG 编码器直接按正确方向写 Exif。两边都要做少了哪一边都会有一处是歪的。3.3 打包成 APK 与产物结构写完代码要出包./gradlew assembleRelease会在app/build/outputs/apk/release/下生成产物。这里有几个容易忽略的配置项android { defaultConfig { minSdkVersion 21 // Camera2 从 API 21 开始 targetSdkVersion 34 ndk { abiFilters arm64-v8a, armeabi-v7a } } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } }minSdkVersion 21是硬门槛低于这个值 Camera2 根本不存在。abiFilters只保留主流架构能把 APK 体积砍掉一大半——如果你的相机 App 引入了图像处理的原生库全架构打包轻松过百兆。出包之后用unzip -l看一眼结构能帮你快速定位为什么这个 APK 这么大unzip -l app-release.apk | sort -k1 -n -r | head -20典型的结构是classes.dex业务代码、resources.arsc资源索引、res/图片布局、lib/abi/原生库通常是体积大头、assets/滤镜模型、LUT 文件、META-INF/签名信息。如果assets/里塞了一个几十兆的 AI 模型那体积问题就有答案了。安装到真机用adb install -r app-release.apk。这里有个小提醒别把系统相机 APK 拿来反编译当模板那既涉及版权风险也几乎不可能在别的机型上跑起来——系统相机对私有 HAL 接口、厂商自有库、系统签名权限有强依赖换个设备打开就是一片黑。想学它的交互逻辑看它怎么摆布 UI 就够了实现路径得自己走。4. 原生模式里真正可调的那几个参数4.1 手动曝光三件套的联动关系很多手机的系统相机里有个专业模式能调 ISO、快门、对焦。用户以为这是相机的硬件能力实际上在 App 侧对应的就是MANUAL_SENSOR这一项能力。开启方式是把 AE 关掉然后直接下发传感器参数val builder device.createCaptureRequest(CameraDevice.TEMPLATE_MANUAL) builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_OFF) builder.set(CaptureRequest.SENSOR_EXPOSURE_TIME, exposureNs) // 纳秒 builder.set(CaptureRequest.SENSOR_SENSITIVITY, iso) // ISO 值 builder.set(CaptureRequest.SENSOR_FRAME_DURATION, 33_333_333L) // 30fps 一帧 builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDiopter) // 屈光度0 表示无穷远这里有三条必须记住的联动规则写错了就是全黑或者全白。第一CONTROL_AE_MODE_OFF之后曝光时间和 ISO 必须同时设置。只设其中一个另一个的值是未定义的部分机型会取上一次的值另一些会取 0表现就是画面突然黑掉。第二曝光时间不能超过帧时长。在 30fps 下帧时长是 33.3 毫秒如果你想要 1/4 秒的长曝光就必须同时把SENSOR_FRAME_DURATION提到 250 毫秒以上否则相机的输出帧率会被强行拉低甚至直接丢帧。做长曝光夜景的时候用户看到的就是预览卡成幻灯片。第三对焦距离是屈光度不是米。LENS_FOCUS_DISTANCE的取值范围来自LENS_INFO_MINIMUM_FOCUS_DISTANCE单位是 1/米。0 表示对焦到无穷远拍风景10 表示对焦到 0.1 米10 厘米微距。很多人直接塞了个米进去结果怎么调都是模糊的。手动白平衡是另一组CONTROL_AWB_MODE设为OFF后通过COLOR_CORRECTION_GAINS传 RGB 三个增益值。这个值的范围在SENSOR_INFO_COLOR_FILTER_ARRANGEMENT相关的特性里并不直接给出实践中常用的做法是做一次简单的灰卡标定或者干脆只提供色温滑块用查找表映射到增益。4.2 RAW 输出与 DNG 落盘想要真正拿到未经 ISP 处理的原始数据路径只有一条ImageFormat.RAW_SENSOR。前提是设备的REQUEST_AVAILABLE_CAPABILITIES里包含RAW这一步必须先判断否则ImageReader.newInstance会直接抛异常。val rawReader ImageReader.newInstance(rawW, rawH, ImageFormat.RAW_SENSOR, 2) rawReader.setOnImageAvailableListener({ reader - val image reader.acquireNextImage() ?: returnsetOnImageAvailableListener // 必须持有拍照时的 CaptureResultDngCreator 依赖其中的元数据 val result pendingResult ?: returnsetOnImageAvailableListener try { FileOutputStream(dngFile).use { out - DngCreator(characteristics, result).use { creator - creator.setOrientation(ExifInterface.ORIENTATION_NORMAL) creator.writeImage(out, image) } } } finally { image.close() } }, cameraHandler)DngCreator这个类很多人不知道它能把RAW_SENSOR输出的 Bayer 数据和对应的CaptureResult元数据打包成一个标准的 DNG 文件直接丢进主流修图软件就能打开。省掉了自己写 Bayer 解析和 TIFF 封装的麻烦。但要注意两个硬性约束。一是CaptureResult必须和这张 RAW 图是同一次曝光的用capture()的回调里拿到的那个 result别用setRepeatingRequest的。二是 RAW 数据量很大一张 12MP 的 RAW 大约 24MB写入速度跟不上就会丢帧所以 RAW 流一般不和 JPEG 流同时开改成拍完 RAW 之后再做一次 JPEG 重处理或者干脆只存 DNG。我实测下来的感受是RAW 适合做后期不适合做即时分享。如果你做的是一个行业采集工具比如需要保留最大后期空间的档案翻拍RAW 值得开如果是给普通用户用的拍照 App把多帧合成的 JPEG 做好收益比 RAW 大得多。5. 上真机才能撞见的六个坑与排查链路5.1 预览黑屏从权限到 Surface 的逐层排除黑屏是相机开发里出现频率最高的问题但它的成因至少有五六种盲改代码只会越改越乱。我总结了一套固定的排查顺序从外到内逐层收窄第一层权限。检查checkSelfPermission(CAMERA)是否真的返回GRANTED。有一种隐蔽情况是权限声明了但uses-feature没写某些定制系统会拦截。日志里搜SecurityException能确认。第二层设备是否被占用。openCamera的回调里如果收到ERROR_CAMERA_IN_USE或MAX_CAMERAS_IN_USE说明有别的进程占着摄像头可能是上一个没退干净的实例也可能是系统相机在前台。这时候检查自己有没有在onPause里正确close()。第三层Surface 是否就绪。这一层最容易翻车。TextureView必须在onSurfaceTextureAvailable回调之后才能拿SurfaceTexture如果你在onCreate里就直接去创建会话surfaceTexture是 null会话创建失败但可能不报错。第四层有没有下发setRepeatingRequest。会话配置成功只是合同签了还没开始干活。忘了这一句预览区域就是一片纯色。第五层Surface的尺寸。如果你给SurfaceTexture设置的默认缓冲尺寸和实际预览尺寸不一致画面会被拉伸或只显示一角。在onSurfaceTextureAvailable里调setDefaultBufferSize(previewW, previewH)。第六层会话配置是否被静默降级。有些机型三路输出配置成功了但实际只出图不刷新表现就是第一帧之后卡住。这时候把 YUV 那一路去掉重试能立刻验证。把这六层按顺序过一遍95% 的黑屏问题能定位到。剩下的 5% 通常是厂商 ROM 的特殊行为比如某些定制系统要求相机 Activity 使用特定的screenOrientation或者对后台启动相机有额外限制。5.2 拍照瞬间的卡顿与帧率跌落按下快门的一瞬间画面卡住半秒是用户最容易感知的体验问题。原因通常不是编码慢而是会话重配置。如果你在拍照时用createCaptureSession重新配置一遍比如把预览流换成拍照流系统要重新分配 ISP 资源这个动作在部分机型上要 300 到 800 毫秒。正确做法是会话在启动时一次性配好拍照时不重建会话。在同一个会话里用capture()下发一个TEMPLATE_STILL_CAPTURE请求目标是已经存在的 JPEG Surface 就够了val stillBuilder device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply { addTarget(jpegReader.surface) set(CaptureRequest.JPEG_ORIENTATION, jpegRotation) set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) } session.capture(stillBuilder.build(), object : CameraCaptureSession.CaptureCallback() { override fun onCaptureStarted(session: CameraCaptureSession, request: CaptureRequest, timestamp: Long, frameNumber: Long) { // 这里做快门动画最合适比在 onCaptureCompleted 里做体验好得多 } }, cameraHandler)onCaptureStarted是触发快门动画的最佳时机——它比onCaptureCompleted早得多用户按下去立刻就有反馈即使实际编码要几百毫秒感知上也觉得快。另一个影响帧率的点是 JPEG 尺寸。如果你在 4K 尺寸下做连拍每一帧都要走一次大尺寸编码帧率必然掉。做连拍的正确姿势是用captureBurst一次性提交多个请求让底层自己去排队而不是在 Java 层循环调capture()——后者会导致请求堆积表现就是越拍越慢。5.3 前后台切换后的设备占用与恢复相机的生命周期管理有个反直觉的点onPause里必须释放摄像头。Android 的摄像头是独占资源你不释放别的应用包括系统相机就打不开用户会觉得你这个 App 有毒。释放的顺序有讲究顺序错了会偶发报错override fun onPause() { captureSession?.close() captureSession null cameraDevice?.close() cameraDevice null imageReader?.close() imageReader null super.onPause() }先关会话再关设备最后关 Reader。反过来关会报IllegalStateException因为会话还引用着已经被关闭的 Surface。恢复的时候不能只在onResume里直接openCamera。要判断TextureView.isAvailable如果不可用得等onSurfaceTextureAvailable回调再打开。很多人在onResume里直接开从后台切回来偶尔黑屏就是这个原因。还有一个坑HandlerThread的生命周期。如果你在onDestroy里quit()了相机线程但 Activity 因为配置变化比如旋转屏幕重建了新的实例又去start()一个新线程旧的线程如果没彻底退出就会有两个线程抢同一个设备。稳妥的做法是在AndroidManifest.xml里给相机 Activity 加android:configChangesorientation|screenSize|keyboardHidden自己处理旋转避免重建。6. 向系统相机画质靠拢的几条工程手段6.1 多帧合成用时间换动态范围既然拿不到厂商的多帧算法那就自己做一个简化版。原理很简单在极短时间内连拍多张不同曝光的图然后做加权融合。正常曝光保中间调欠曝那张保高光细节过曝那张提暗部信噪比。具体实现路径是用TEMPLATE_MANUAL下发三组不同的曝光参数用captureBurst一次性提交然后在 YUV 数据上做融合。融合的算法可以从最简单的 Mertens 曝光融合开始也可以用拉普拉斯金字塔。需要注意几点三张的间隔必须足够小否则手抖会带来鬼影。用captureBurst而不是循环capture能把间隔压到几十毫秒。一定要做对齐。哪怕手机放在桌上快门动作本身也会带来微小位移。用灰度投影或者简单的块匹配做粗对齐成本很低但效果明显。融合完要做一次局部对比度增强否则出图会显得平。我在一个模拟项目里做过对比单帧出图的高光区域细节几乎全丢三帧融合之后能拉回大概 1.5 档的动态范围。这个提升不会让画质追上系统相机但已经从一眼廉价变成了能看。6.2 GPU 后处理管线与色彩管理CPU 上做 YUV 转 RGB、降噪、锐化在 12MP 的图上基本是秒级起步用户体验无法接受。正确做法是把整个后处理搬到 GPUYUV 纹理上传 → 着色器里做色彩空间转换 → 应用 LUT 调色 → 锐化 → 输出到离屏 FBO → 编码。关键点在于YUV 三个平面的采样。ImageFormat.YUV_420_888在内存里可能是 NV21、NV12 或者三个独立平面取决于厂商。读取的时候不能假设布局必须按Image.Plane的rowStride和pixelStride动态计算偏移val yPlane image.planes[0] val uPlane image.planes[1] val vPlane image.planes[2] // 上传为三个 GL_LUMINANCE 纹理在片段着色器里按 BT.601/709 矩阵合并这里有个高频错误直接按width * height读取 Y 平面忽略了rowStride可能大于width为了内存对齐。结果就是图像右侧出现斜向条纹。判断方法很简单打印rowStride和width不相等就说明有 padding。色彩空间也要注意。CaptureResult里的COLOR_CORRECTION_TRANSFORM和SENSOR_INFO_COLOR_FILTER_ARRANGEMENT决定了正确的转换矩阵但大部分场景下用标准 BT.709 就够。如果你的应用对色彩准确性有要求比如商品拍摄那就得读元数据做精确转换这一步能拉开明显的差距。7. 几个我踩过才记住的细节关于原生相机 APK这个方向还有几件事值得单独拎出来说。不要在onSurfaceTextureDestroyed里返回 false。这个返回值决定系统要不要回收 SurfaceTexture返回 false 意味着你自己负责释放但实际上大部分场景下应该返回 true 让系统回收然后在下次onSurfaceTextureAvailable里重建。返回 false 会导致 SurfaceTexture 泄漏反复切换页面之后相机就打不开了。ImageReader一定要及时close()。每张图如果不关闭缓冲区很快耗尽表现是拍了几张之后再也不回调。这个 bug 特别阴险因为前几张完全正常。预览的SurfaceView别放在ScrollView里。SurfaceView 是独立绘图层滚动时会出现画面追不上手指的撕裂感。如果确实需要在可滚动页面里做预览用 TextureView。适配刘海屏和挖孔屏。相机预览区域如果用fitsSystemWindows在部分机型上会被裁掉一条边导致取景框和实际成像范围不一致。用WindowInsets手动处理安全区域更可靠。上架前的合规检查。相机类应用在应用市场审核时需要提供隐私政策说明摄像头用途并且在首次启动时明确告知。这一条不过包都传不上去代码写得再好也白搭。别迷信高分辨率。很多场景下 12MP 的输出质量比 48MP 好——因为高像素模式下单个像素的进光量小暗部噪点会明显增加而且处理时间成倍增长。我在室内场景实测过48MP 出图放大到 100% 看细节反而不如 12MP 的像素合并结果。默认给用户 12MP把高像素做成一个可选开关是更稳的产品决策。8. 说在最后做相机 App 这件事代码量其实不大难的是对设备差异的容忍度。同一段逻辑在某些机型上一次跑通在另一些机型上要调三天。我自己的经验是把每一台测试机的能力画像都打印出来存档建立一张机型 × 支持能力 × 已知问题的表格比任何文档都值钱。每次遇到新的崩溃或者黑屏先查表再查代码效率能提升好几倍。另外一个建议是前期别急着做多帧合成、RAW、手动模式这些高级功能。先把打开不黑屏、预览不变形、拍照不卡顿、切后台不占用这四件事做扎实这四项覆盖了用户 90% 的实际使用场景。剩下的那些花活等基础稳了再往上加否则你会陷入新功能引入新 bug旧 bug 还没修完的泥潭里出不来。