1. 为什么相机适配是个绕不开的坎做过 Android 相机相关开发的人都有一个共同感受这玩意儿看起来简单真上手了到处都是坑。调用系统相机拍张照在 Android 6.0 上跑得好好的到了 7.0 直接崩了自定义相机预览画面在人家的全面屏手机上要么拉伸变形要么黑边一大截。你说气不气人。这篇文章要聊的就是两个在实际项目中高频出现的问题一是Android 7.0 及以上版本调用系统相机时的适配核心就是 FileProvider 那一套东西二是自定义相机在全面屏手机上的预览适配涉及到预览比例计算、刘海区域处理、Cutout 模式配置等。这两个问题基本上覆盖了日常开发中 80% 的相机适配场景剩下的 20% 比如多摄像头切换、高帧率录制、Camera2 与 CameraX 的选型我会在后续系列里单独展开。不管你是刚接触 Android 相机开发的新手还是被适配问题折腾过的老手这篇内容都能给你一些可以直接抄作业的方案。我尽量把每个参数为什么这么算、每个配置为什么这么写都讲清楚而不是甩一段代码让你自己悟。2. 调用系统相机的完整适配方案2.1 从 Uri.fromFile 到 FileProvider 的演变逻辑早些年调用系统相机拍照代码大概长这样Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); File photoFile new File(Environment.getExternalStorageDirectory(), photo.jpg); Uri photoUri Uri.fromFile(photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); startActivityForResult(intent, REQUEST_CODE);这段代码在 Android 6.0 及以下跑得没问题但到了 7.0 就会直接抛FileUriExposedException。原因在于 Android 7.0 引入了StrictMode严格模式策略禁止应用之间通过file://类型的 Uri 传递文件。为什么禁止因为file://Uri 会让接收方获得该文件的完整访问权限存在安全隐患。比如你拍了一张私密照片通过file://传给某个第三方相机应用那个应用就能读取你整个目录下的文件。解决方案就是FileProvider——它是ContentProvider的一个子类通过content://类型的 Uri 来共享文件可以精确控制哪些目录、哪些文件能被访问并且支持临时授权。这个设计思路其实和 Web 开发中的签名 URL 很像都是通过一个中间层来控制访问权限。2.2 FileProvider 配置的四个关键步骤配置 FileProvider 需要四步每一步都有容易踩的坑我逐个说。第一步在 AndroidManifest.xml 中声明 Provider。provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider这里有几个细节值得注意。authorities建议用${applicationId}.fileprovider的格式这样在多模块或者多 flavor 的项目里不会冲突。exported必须设为false否则会报安全警告。grantUriPermissions设为true才能临时授权给其他应用。第二步创建 res/xml/file_paths.xml 配置文件。?xml version1.0 encodingutf-8? paths external-files-path nameimages pathimages/ / cache-path namecache_images pathcamera/ / external-cache-path nameexternal_cache path. / /pathspaths标签下可以配置多种类型的路径常用的有这几个标签名对应路径适用场景files-pathContext.getFilesDir()应用内部私有文件cache-pathContext.getCacheDir()应用内部缓存external-files-pathContext.getExternalFilesDir()外部存储私有目录external-cache-pathContext.getExternalCacheDir()外部存储缓存目录external-pathEnvironment.getExternalStorageDirectory()外部存储根目录不推荐我个人的建议是优先用external-cache-path或者external-files-path因为这两个目录在应用卸载时会自动清理不会在用户手机上留下垃圾文件。用external-path虽然也能用但会污染用户的存储空间体验不好。第三步生成 Content Uri。File photoFile new File(getExternalCacheDir(), camera/photo_ System.currentTimeMillis() .jpg); if (!photoFile.getParentFile().exists()) { photoFile.getParentFile().mkdirs(); } Uri photoUri FileProvider.getUriForFile( this, getPackageName() .fileprovider, photoFile );注意getUriForFile的第二个参数必须和 Manifest 里的authorities完全一致否则会抛IllegalArgumentException。另外文件所在的目录必须在file_paths.xml中配置过否则同样会抛异常。第四步启动相机并授权。Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); if (intent.resolveActivity(getPackageManager()) ! null) { startActivityForResult(intent, REQUEST_CODE_CAMERA); }FLAG_GRANT_WRITE_URI_PERMISSION是必须加的否则相机应用没有权限往这个 Uri 写入照片。FLAG_GRANT_READ_URI_PERMISSION看情况加如果你后续还要读取这个文件的话建议一起加上。注意有些国产 ROM 在调用系统相机时即使加了这两个 flag 也可能出现权限问题。实测下来在部分机型上需要额外调用grantUriPermission方法手动授权给相机应用包名这个后面在常见问题里会详细说。2.3 权限申请与 Android 10 的适配Android 6.0 开始需要动态申请CAMERA和WRITE_EXTERNAL_STORAGE权限这个大家都知道。但 Android 10 引入了分区存储Scoped Storage之后情况变得复杂了。如果你把照片存在getExternalCacheDir()或者getExternalFilesDir()下是不需要申请存储权限的因为这些是应用私有目录。但如果你要往相册里插一张图就需要用MediaStoreAPI 来操作。// Android 10 插入相册 ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, photo_ System.currentTimeMillis() .jpg); values.put(MediaStore.Images.Media.MIME_TYPE, image/jpeg); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp); Uri insertUri getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);然后在启动相机时把EXTRA_OUTPUT设成这个insertUri。这样拍完照之后系统相机会自动把照片写入这个 Uri 对应的位置同时也会出现在系统相册里。对于 Android 9 及以下还是得走 FileProvider 那一套。所以实际项目中通常需要做版本判断private Uri getOutputUri() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // 使用 MediaStore return createMediaStoreUri(); } else { // 使用 FileProvider return createFileProviderUri(); } }2.4 拍照结果的正确处理方式调用系统相机拍照结果的处理有两种方式。一种是在onActivityResult里通过data.getExtras().get(data)获取缩略图但这种方式拿到的图片分辨率极低基本没法用。另一种是通过EXTRA_OUTPUT指定输出路径拍完后从那个路径读取原图。这里有个坑部分机型在拍照取消时也会返回 RESULT_OK导致你去读取一个不存在的文件。所以拿到结果后一定要先判断文件是否存在、大小是否大于 0。Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode REQUEST_CODE_CAMERA) { if (resultCode RESULT_OK) { if (photoFile ! null photoFile.exists() photoFile.length() 0) { // 正常处理照片 Bitmap bitmap BitmapFactory.decodeFile(photoFile.getAbsolutePath()); // ... } else { // 文件不存在可能是用户取消了拍照 Toast.makeText(this, 拍照未完成, Toast.LENGTH_SHORT).show(); } } } }另外拍完照之后系统相机会把图片旋转信息写在 EXIF 里直接用BitmapFactory解码出来的图可能是旋转过的。需要用ExifInterface读取方向信息然后手动旋转ExifInterface exif new ExifInterface(photoFile.getAbsolutePath()); int orientation exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); Matrix matrix new Matrix(); switch (orientation) { case ExifInterface.ORIENTATION_ROTATE_90: matrix.postRotate(90); break; case ExifInterface.ORIENTATION_ROTATE_180: matrix.postRotate(180); break; case ExifInterface.ORIENTATION_ROTATE_270: matrix.postRotate(270); break; } Bitmap rotatedBitmap Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);3. 自定义相机在全面屏上的适配核心3.1 全面屏带来的三个新问题自定义相机这里以 Camera1 API 为例Camera2 和 CameraX 的思路类似在普通 16:9 屏幕上跑得好好的换到全面屏手机上就会遇到三个问题。问题一预览画面拉伸变形。全面屏手机的屏幕比例通常是 19.5:9、20:9 甚至 21:9而相机传感器输出的预览比例通常是 4:3 或 16:9。如果你直接把预览画面铺满整个 SurfaceView画面就会被拉伸人脸变长、物体变形。问题二黑边问题。如果你保持预览比例不变按宽度适配那上下就会出现黑边按高度适配左右就会出现黑边。在全面屏上这个黑边会特别明显。问题三刘海/挖孔区域遮挡。全面屏手机顶部通常有刘海或者挖孔如果预览画面延伸到那个区域内容会被遮挡。而且不同厂商的刘海形状、大小都不一样适配起来很头疼。3.2 预览比例的计算逻辑解决拉伸变形的核心思路是找到一个和屏幕比例最接近的相机预览尺寸然后按比例缩放 SurfaceView让预览画面完整显示且不变形。具体怎么做先获取相机支持的所有预览尺寸然后遍历这些尺寸找到和屏幕宽高比差值最小的那个。private Camera.Size getBestPreviewSize(Camera camera, int screenWidth, int screenHeight) { Camera.Parameters parameters camera.getParameters(); ListCamera.Size previewSizes parameters.getSupportedPreviewSizes(); float screenRatio (float) screenWidth / screenHeight; float minDiff Float.MAX_VALUE; Camera.Size bestSize null; for (Camera.Size size : previewSizes) { float ratio (float) size.width / size.height; float diff Math.abs(ratio - screenRatio); if (diff minDiff) { minDiff diff; bestSize size; } } return bestSize; }拿到最佳预览尺寸后需要计算 SurfaceView 的实际显示大小。假设预览尺寸是 1920x108016:9屏幕是 2340x108019.5:9那么按宽度适配SurfaceView 宽度 屏幕宽度 1080高度 1080 * (1080/1920) 607.5按高度适配SurfaceView 高度 屏幕高度 2340宽度 2340 * (1920/1080) 4160超出屏幕显然按宽度适配更合理但这样上下会有黑边。如果你能接受黑边那这样就行了。如果不能接受黑边就需要用TextureView配合Matrix做裁剪缩放把画面放大到铺满屏幕超出部分裁掉。// 使用 TextureView 做裁剪缩放 private void adjustTextureViewSize(TextureView textureView, int previewWidth, int previewHeight, int screenWidth, int screenHeight) { float scaleX (float) screenWidth / previewWidth; float scaleY (float) screenHeight / previewHeight; float scale Math.max(scaleX, scaleY); Matrix matrix new Matrix(); matrix.setScale(scale, scale); // 居中裁剪 matrix.postTranslate((screenWidth - previewWidth * scale) / 2f, (screenHeight - previewHeight * scale) / 2f); textureView.setTransform(matrix); }这里用Math.max而不是Math.min是关键。用max意味着画面会被放大到至少铺满一个方向另一个方向超出部分被裁掉这样就不会有黑边。用min的话画面会完整显示但有黑边。实操心得如果你的应用对画面完整性要求高比如证件扫描建议用min保留黑边如果是拍照预览比如社交分享建议用max裁剪铺满视觉体验更好。3.3 刘海屏与 Cutout 模式的配置Android 9API 28开始支持刘海屏提供了layoutInDisplayCutoutMode属性来控制内容是否延伸到刘海区域。有三个可选值模式说明适用场景default默认行为内容不延伸到刘海区域普通页面shortEdges内容延伸到刘海区域但刘海区域不显示内容全屏预览、视频播放never内容永不延伸到刘海区域对布局要求严格的页面always内容始终延伸到刘海区域特殊场景对于相机预览通常建议用shortEdges让预览画面延伸到刘海区域但 UI 控件比如拍照按钮、切换摄像头按钮要避开刘海区域。style nameCameraTheme parentTheme.AppCompat.NoActionBar item nameandroid:windowLayoutInDisplayCutoutModeshortEdges/item item nameandroid:windowTranslucentStatustrue/item item nameandroid:windowTranslucentNavigationtrue/item /style然后在代码中获取刘海区域的安全距离if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { DisplayCutout cutout getWindow().getDecorView().getRootWindowInsets().getDisplayCutout(); if (cutout ! null) { int topInset cutout.getSafeInsetTop(); int bottomInset cutout.getSafeInsetBottom(); // 根据安全距离调整 UI 控件位置 cameraButton.setTranslationY(-bottomInset); topBar.setPadding(0, topInset, 0, 0); } }3.4 预览方向与拍照方向的校正相机预览方向这个问题在横竖屏切换的时候特别容易出问题。核心原因是相机传感器的方向是固定的而设备可以旋转。你需要根据设备当前旋转角度和相机传感器方向来计算预览的旋转角度。private int getDisplayOrientation(int cameraId, Camera camera) { Camera.CameraInfo info new Camera.CameraInfo(); Camera.getCameraInfo(cameraId, info); int rotation getWindowManager().getDefaultDisplay().getRotation(); int degrees 0; switch (rotation) { case Surface.ROTATION_0: degrees 0; break; case Surface.ROTATION_90: degrees 90; break; case Surface.ROTATION_180: degrees 180; break; case Surface.ROTATION_270: degrees 270; break; } int result; if (info.facing Camera.CameraInfo.CAMERA_FACING_FRONT) { result (info.orientation degrees) % 360; result (360 - result) % 360; // 前置摄像头需要镜像 } else { result (info.orientation - degrees 360) % 360; } return result; }拍照方向的校正类似但要注意前置摄像头拍出来的照片是镜像的需要额外做一次水平翻转。private int getPictureOrientation(int cameraId) { Camera.CameraInfo info new Camera.CameraInfo(); Camera.getCameraInfo(cameraId, info); int rotation getWindowManager().getDefaultDisplay().getRotation(); int degrees 0; switch (rotation) { case Surface.ROTATION_0: degrees 0; break; case Surface.ROTATION_90: degrees 90; break; case Surface.ROTATION_180: degrees 180; break; case Surface.ROTATION_270: degrees 270; break; } if (info.facing Camera.CameraInfo.CAMERA_FACING_FRONT) { return (info.orientation degrees) % 360; } else { return (info.orientation - degrees 360) % 360; } }4. 完整实操流程与关键代码4.1 项目结构与依赖配置先说一下项目结构。我一般会把相机相关的代码拆成几个模块CameraHelper负责相机初始化和参数配置PreviewManager负责预览尺寸计算和 SurfaceView/TextureView 的适配PhotoProcessor负责拍照后的图片旋转、压缩、保存。依赖方面如果用 Camera1 API 不需要额外依赖用 Camera2 建议加上androidx.camera:camera-core和androidx.camera:camera-camera2用 CameraX 的话再加上androidx.camera:camera-lifecycle和androidx.camera:camera-view。dependencies { // CameraX 相关可选 implementation androidx.camera:camera-core:1.3.0 implementation androidx.camera:camera-camera2:1.3.0 implementation androidx.camera:camera-lifecycle:1.3.0 implementation androidx.camera:camera-view:1.3.0 // EXIF 解析 implementation androidx.exifinterface:exifinterface:1.3.6 }4.2 系统相机调用的完整封装我把系统相机调用封装成了一个SystemCameraHelper类对外只暴露一个takePhoto方法。public class SystemCameraHelper { private static final int REQUEST_CODE_CAMERA 1001; private File outputFile; private Uri outputUri; public void takePhoto(Activity activity) { Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(activity.getPackageManager()) null) { Toast.makeText(activity, 未找到相机应用, Toast.LENGTH_SHORT).show(); return; } outputFile createOutputFile(activity); outputUri getOutputUri(activity, outputFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // 部分国产 ROM 需要手动授权 ListResolveInfo resInfoList activity.getPackageManager() .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); for (ResolveInfo resolveInfo : resInfoList) { String packageName resolveInfo.activityInfo.packageName; activity.grantUriPermission(packageName, outputUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); } activity.startActivityForResult(intent, REQUEST_CODE_CAMERA); } private File createOutputFile(Activity activity) { File dir new File(activity.getExternalCacheDir(), camera); if (!dir.exists()) { dir.mkdirs(); } return new File(dir, IMG_ System.currentTimeMillis() .jpg); } private Uri getOutputUri(Activity activity, File file) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { ContentValues values new ContentValues(); values.put(MediaStore.Images.Media.DISPLAY_NAME, file.getName()); values.put(MediaStore.Images.Media.MIME_TYPE, image/jpeg); values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp); return activity.getContentResolver() .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); } else { return FileProvider.getUriForFile(activity, activity.getPackageName() .fileprovider, file); } } public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data, OnPhotoResultListener listener) { if (requestCode ! REQUEST_CODE_CAMERA) return; if (resultCode Activity.RESULT_OK) { if (outputFile ! null outputFile.exists() outputFile.length() 0) { listener.onSuccess(outputFile); } else if (outputUri ! null) { // Android 10 从 MediaStore 读取 listener.onSuccess(readFromMediaStore(activity, outputUri)); } else { listener.onCancel(); } } else { listener.onCancel(); } } public interface OnPhotoResultListener { void onSuccess(File photoFile); void onCancel(); } }4.3 自定义相机预览适配的完整实现自定义相机这块我用TextureView来做预览因为TextureView支持变换矩阵做裁剪缩放比较方便。public class CustomCameraView extends FrameLayout { private TextureView textureView; private Camera camera; private int cameraId 0; private int previewWidth; private int previewHeight; public CustomCameraView(Context context) { super(context); init(); } private void init() { textureView new TextureView(getContext()); addView(textureView, new LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); textureView.setSurfaceTextureListener(surfaceTextureListener); } private final TextureView.SurfaceTextureListener surfaceTextureListener new TextureView.SurfaceTextureListener() { Override public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { openCamera(width, height); } Override public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { configureTransform(width, height); } Override public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { releaseCamera(); return true; } Override public void onSurfaceTextureUpdated(SurfaceTexture surface) {} }; private void openCamera(int width, int height) { camera Camera.open(cameraId); Camera.Parameters params camera.getParameters(); // 选择最佳预览尺寸 Camera.Size bestSize getBestPreviewSize(camera, width, height); previewWidth bestSize.width; previewHeight bestSize.height; params.setPreviewSize(previewWidth, previewHeight); // 选择最佳拍照尺寸 Camera.Size bestPictureSize getBestPictureSize(camera, width, height); params.setPictureSize(bestPictureSize.width, bestPictureSize.height); // 设置对焦模式 if (params.getSupportedFocusModes().contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); } camera.setParameters(params); // 设置预览方向 int displayOrientation getDisplayOrientation(cameraId, camera); camera.setDisplayOrientation(displayOrientation); try { camera.setPreviewTexture(textureView.getSurfaceTexture()); camera.startPreview(); } catch (IOException e) { e.printStackTrace(); } configureTransform(width, height); } private void configureTransform(int viewWidth, int viewHeight) { if (previewWidth 0 || previewHeight 0) return; float scaleX (float) viewWidth / previewWidth; float scaleY (float) viewHeight / previewHeight; float scale Math.max(scaleX, scaleY); Matrix matrix new Matrix(); matrix.setScale(scale, scale); matrix.postTranslate( (viewWidth - previewWidth * scale) / 2f, (viewHeight - previewHeight * scale) / 2f ); textureView.setTransform(matrix); } private Camera.Size getBestPreviewSize(Camera camera, int screenWidth, int screenHeight) { Camera.Parameters parameters camera.getParameters(); ListCamera.Size previewSizes parameters.getSupportedPreviewSizes(); float screenRatio (float) screenWidth / screenHeight; float minDiff Float.MAX_VALUE; Camera.Size bestSize null; for (Camera.Size size : previewSizes) { float ratio (float) size.width / size.height; float diff Math.abs(ratio - screenRatio); if (diff minDiff) { minDiff diff; bestSize size; } } return bestSize ! null ? bestSize : previewSizes.get(0); } private Camera.Size getBestPictureSize(Camera camera, int screenWidth, int screenHeight) { Camera.Parameters parameters camera.getParameters(); ListCamera.Size pictureSizes parameters.getSupportedPictureSizes(); float screenRatio (float) screenWidth / screenHeight; float minDiff Float.MAX_VALUE; Camera.Size bestSize null; for (Camera.Size size : pictureSizes) { float ratio (float) size.width / size.height; float diff Math.abs(ratio - screenRatio); // 优先选择比例接近且分辨率较高的 if (diff minDiff size.width 1920) { minDiff diff; bestSize size; } } return bestSize ! null ? bestSize : pictureSizes.get(0); } private int getDisplayOrientation(int cameraId, Camera camera) { Camera.CameraInfo info new Camera.CameraInfo(); Camera.getCameraInfo(cameraId, info); int rotation getDisplay().getRotation(); int degrees 0; switch (rotation) { case Surface.ROTATION_0: degrees 0; break; case Surface.ROTATION_90: degrees 90; break; case Surface.ROTATION_180: degrees 180; break; case Surface.ROTATION_270: degrees 270; break; } int result; if (info.facing Camera.CameraInfo.CAMERA_FACING_FRONT) { result (info.orientation degrees) % 360; result (360 - result) % 360; } else { result (info.orientation - degrees 360) % 360; } return result; } public void takePicture(final OnPictureTakenListener listener) { camera.takePicture(null, null, new Camera.PictureCallback() { Override public void onPictureTaken(byte[] data, Camera camera) { // 处理图片旋转 Bitmap bitmap BitmapFactory.decodeByteArray(data, 0, data.length); int orientation getPictureOrientation(cameraId); Bitmap rotatedBitmap rotateBitmap(bitmap, orientation); listener.onPictureTaken(rotatedBitmap); camera.startPreview(); } }); } private Bitmap rotateBitmap(Bitmap bitmap, int orientation) { if (orientation 0) return bitmap; Matrix matrix new Matrix(); matrix.postRotate(orientation); return Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); } public void releaseCamera() { if (camera ! null) { camera.stopPreview(); camera.release(); camera null; } } public interface OnPictureTakenListener { void onPictureTaken(Bitmap bitmap); } }4.4 全面屏适配的布局配置在 Activity 的布局中需要做几个配置来适配全面屏。androidx.constraintlayout.widget.ConstraintLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent android:backgroundandroid:color/black com.example.camera.CustomCameraView android:idid/camera_view android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / ImageButton android:idid/btn_capture android:layout_width72dp android:layout_height72dp android:srcdrawable/ic_capture android:backgrounddrawable/bg_capture_button app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent android:layout_marginBottom48dp / ImageButton android:idid/btn_switch android:layout_width48dp android:layout_height48dp android:srcdrawable/ic_switch_camera app:layout_constraintTop_toTopOfparent app:layout_constraintEnd_toEndOfparent android:layout_marginTop24dp android:layout_marginEnd24dp / /androidx.constraintlayout.widget.ConstraintLayout然后在 Activity 中处理刘海区域的安全距离public class CameraActivity extends AppCompatActivity { private CustomCameraView cameraView; private ImageButton btnCapture; private ImageButton btnSwitch; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 全屏显示 getWindow().setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN ); setContentView(R.layout.activity_camera); cameraView findViewById(R.id.camera_view); btnCapture findViewById(R.id.btn_capture); btnSwitch findViewById(R.id.btn_switch); adjustForCutout(); btnCapture.setOnClickListener(v - { cameraView.takePicture(bitmap - { // 保存图片 saveBitmap(bitmap); }); }); btnSwitch.setOnClickListener(v - { cameraView.switchCamera(); }); } private void adjustForCutout() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { View decorView getWindow().getDecorView(); decorView.post(() - { DisplayCutout cutout decorView.getRootWindowInsets().getDisplayCutout(); if (cutout ! null) { int topInset cutout.getSafeInsetTop(); int bottomInset cutout.getSafeInsetBottom(); // 调整顶部按钮位置 ViewGroup.MarginLayoutParams switchParams (ViewGroup.MarginLayoutParams) btnSwitch.getLayoutParams(); switchParams.topMargin topInset dp2px(16); btnSwitch.setLayoutParams(switchParams); // 调整底部按钮位置 ViewGroup.MarginLayoutParams captureParams (ViewGroup.MarginLayoutParams) btnCapture.getLayoutParams(); captureParams.bottomMargin bottomInset dp2px(48); btnCapture.setLayoutParams(captureParams); } }); } } private int dp2px(int dp) { return (int) (dp * getResources().getDisplayMetrics().density 0.5f); } Override protected void onDestroy() { super.onDestroy(); cameraView.releaseCamera(); } }5. 常见问题与排查技巧实录5.1 FileProvider 相关异常速查异常信息原因解决方案FileUriExposedException使用了file://Uri改用 FileProvider 生成content://UriIllegalArgumentException: Failed to find configured root文件路径未在 file_paths.xml 中配置检查文件所在目录是否在配置的 path 范围内SecurityException: Permission Denial未授予 Uri 读写权限添加FLAG_GRANT_WRITE_URI_PERMISSION并手动 grantUriPermissionFileNotFoundException文件目录不存在在创建文件前先mkdirs()拍照返回 RESULT_OK 但文件为空部分 ROM 取消拍照也返回 OK判断文件是否存在且 length 05.2 预览画面异常的排查思路预览画面出问题排查顺序建议是先看比例再看方向最后看裁剪。比例问题打印出相机支持的预览尺寸列表和屏幕尺寸手动算一下哪个比例最接近。有时候getBestPreviewSize选出来的尺寸在特定机型上会有问题可以加个白名单或者黑名单来规避。方向问题打印info.orientation和getDisplayRotation()的值确认旋转角度计算是否正确。前置摄像头记得做镜像处理。裁剪问题检查Matrix的 scale 计算确认用的是Math.max还是Math.min。如果画面偏移了检查postTranslate的偏移量计算。5.3 我踩过的几个坑坑一部分机型getSupportedPreviewSizes返回的尺寸顺序不固定。有些 ROM 会把最大的尺寸放在第一个有些会放在最后。所以不能假设第一个就是最好的必须遍历比较。坑二TextureView 的onSurfaceTextureSizeChanged在横竖屏切换时触发时机不确定。有时候会在onSurfaceTextureAvailable之前触发导致previewWidth还是 0 的时候就去计算 Matrix。解决办法是在configureTransform里加个判断previewWidth 0时直接返回。坑三Android 10 的 MediaStore 插入 Uri 后如果拍照取消会在相册里留下一张空图片。解决办法是在onActivityResult里判断如果拍照取消就调用getContentResolver().delete(outputUri, null, null)把那条记录删掉。坑四部分国产 ROM 的相机应用不认FLAG_GRANT_WRITE_URI_PERMISSION。这个前面提过需要手动遍历所有能响应相机 Intent 的应用逐个grantUriPermission。虽然麻烦但确实能解决大部分机型的权限问题。坑五全面屏手机上如果用了shortEdges模式预览画面会延伸到刘海区域但拍照出来的照片不包含刘海区域的内容。这会导致预览和实际拍照结果不一致。解决办法是要么把预览画面也限制在安全区域内要么在拍照后做裁剪对齐。我一般选择前者虽然会损失一点显示面积但用户体验更一致。5.4 性能优化建议相机预览是个比较吃性能的操作几个优化点值得注意。预览尺寸不要选太大的。虽然 1920x1080 看起来很美好但在中低端机上可能会导致预览卡顿。建议根据设备性能动态选择或者提供一个设置项让用户自己选。拍照后的图片处理要放到子线程。BitmapFactory.decodeByteArray和旋转操作都是耗时操作放主线程会卡 UI。可以用AsyncTask或者Coroutine来处理。及时释放相机资源。在onPause里调用camera.stopPreview()在onDestroy里调用camera.release()。否则其他应用可能无法使用相机而且会耗电。6. 一些补充说明和后续扩展方向上面这套方案基本上能覆盖大部分机型的相机适配需求。但 Android 的碎片化程度大家都懂总有一些奇葩机型会出问题。我的建议是在条件允许的情况下尽量用CameraX来代替 Camera1/Camera2。CameraX 是 Jetpack 组件封装了大量适配逻辑预览比例、旋转、裁剪这些它都帮你处理好了代码量能减少一半以上。当然CameraX 也不是万能的。如果你的应用需要一些高级功能比如手动控制曝光、对焦区域、RAW 格式输出那还是得用 Camera2。Camera2 的灵活性最高但代码复杂度也最高学习曲线比较陡。后续我计划再写几篇分别聊聊 CameraX 的实战、Camera2 的高级功能、以及多摄像头切换的方案。相机这块内容太多了一篇根本讲不完。最后分享一个我个人的习惯每次遇到相机相关的 bug我都会把机型和系统版本记下来建一个适配问题库。时间长了你会发现很多问题其实是重复的有了这个库下次遇到类似问题直接查就行了能省不少时间。