简介面向需要在安卓应用中快速集成相机、相册选图和头像裁剪上传下载能力的开发者这份压缩包提供了一套可直接导入工程运行的完整示例。资源共321个文件整体约5.12兆字节其中核心源码负责相机相册交互与图片处理界面布局和清单文件声明权限与页面结构构建脚本与资源配置保证工程可编译还包含可直接安装的演示程序以及若干依赖库便于调试和扩展。项目以头像管理器为入口完整演示了动态权限申请、启动系统相机与相册、调用系统裁剪、压缩并保存图片、通过网络库进行多段上传和按地址下载头像的整个流程代码中注明关键参数和操作步骤并配有日志统计工具辅助排错。读者既能从头到尾理解各环节的衔接方式也可将裁剪模块或上传模块抽取复用到其他项目。目前已有2445人学习下载适合有安卓基础、希望掌握图片从本地到服务器全流程的开发者。1. 一张头像图的完整旅程为什么非得用 Creator 桥接 Android 原生Creator 项目里做“调用安卓相机/相册、裁剪、上传头像”第一道坎不是裁剪算法而是“JS 引擎里根本没有相机”。相机、相册、裁剪在安卓上是系统组件提供的 Intent 能力必须由 Native 层发起JS 层只负责发指令、收结果。我拆过一套完整的 Creator 桥接工程JS 通过 JSB 反射调用 Java 静态方法Java 拉起系统相机与相册裁剪后压缩成本地文件再走 OkHttp 上传、下载。这套链路解决了引擎与系统能力之间的“断层”核心价值是把原生交互封装成一个可复用模块。适合谁正在做用户头像上传、用户相册管理、图片审核类功能的 Creator 开发者新手能从零跑通熟手则能直接拿走边界处理。2. 桥接层设计JS 与原生 Java 的调用链路和生命周期绑定2.1 原理JSB 反射与 Activity 的上下文归属先看调用链条JS 调用 - jsb.reflection.callStaticMethod - Java 静态方法 - startActivityForResult - 系统服务返回 - onActivityResult - evalString 回到 JS。这条链路的关键点在于所有系统页面都挂在 Creator 的 Activity 上而 Java 静态方法不持有生命周期所以我们必须先拿到那个 Activity 实例。引擎工程里最常见也最省事的做法是直接用 Creator 内置的 Cocos2dxActivity.getContext() 拿全局上下文而不是自己维护一个静态的 mActivity 引用。前者能避免多入口导致的空指针后者在某些冷启动场景下会直接拿到 null。我在一个模拟项目里见过这种写法翻车冷启动后用户快速点头像Java 侧 mActivity 还没赋值startActivityForResult 抛空指针JS 层只会收到一个“什么都没发生”的静默失败。换成 getContext() 后问题消失。下面是最小探针示例先验证桥接通没通再展开业务逻辑package com.demo.bridge; public class BridgeProbe { public static String greet(String name) { return hello, name; } }// Creator 脚本里仅验证链路时使用 if (cc.sys.isNative) { const ret jsb.reflection.callStaticMethod( com/demo/bridge/BridgeProbe, // 类路径斜杠分隔 greet, // 静态方法名 (Ljava/lang/String;)Ljava/lang/String;, // 方法签名 creator ); cc.log(桥接返回 ret); }最容易写错的是中间那个方法签名。Java 方法 greet(String) 的签名是 (Ljava/lang/String;)Ljava/lang/String;括号里是入参括号后是返回类型。String 对应 Ljava/lang/String;int 对应 Iboolean 对应 Z。如果你把分号漏了JS 层会静默失败不报错也不返回。我一般先在最小探针类上验证签名再展开完整功能避免把链路问题混进业务问题。2.2 带回调的通信从 Java 把结果推回 JS静态方法返回值适合传简单数据但相机、相册这类异步操作的结果发生在 onActivityResult 里JS 端不可能同步等结果。所以完整实现采用“回调 ID evalString”的方案JS 发起调用时把一个回调 ID 塞给 JavaJava 完成后拼接一段 JS 脚本通过 evalString 执行唤起 JS 侧预先注册的回调函数。回调 ID 在这里相当于“原路返回”的凭证这是跨引擎通信的通用约定。// ImageBridge.java 片段统一的 JS 回调出口 private static void emitToJs(String callbackId, String json) { // 必须回主线程执行否则 GL 线程状态下 evalString 可能失效 new Handler(Looper.getMainLooper()).post(new Runnable() { Override public void run() { String script window[cb_ callbackId ] window[cb_ callbackId ]( json );; Cocos2dxJavascriptJavaBridge.evalString(script); } }); }// Creator 侧注册全局回调JS 与 Java 的桥接点 window[cb_demo001] function (jsonStr) { const data JSON.parse(jsonStr); cc.log(原生返回, data); }; const fileName avatar_ Date.now() .jpg; jsb.reflection.callStaticMethod( com/demo/bridge/ImageBridge, openCamera, (Ljava/lang/String;Ljava/lang/String;)V, // void 返回末尾的 V fileName, demo001 );这个方案的隐蔽坑在 evalString 的线程约束。我在真机上遇到过回调“时有时无”的问题后来定位到是子线程里直接调 evalStringGL 线程状态不对导致脚本执行被丢弃。用 Handler 把回调 post 到主线程后问题消失。另一个习惯是脚本里先判断函数存在再调用避免第二次操作时回调已被清理导致的 ReferenceError。回调 ID 建议由 JS 侧生成格式用时间戳加随机串避免纯数字和系统内部回调撞车。2.3 生命周期约定onActivityResult 集中在哪儿处理桥接层的最后一块拼图是 onActivityResult 的归属。我见过把 onActivityResult 分散写在多个模块里的工程维护成本很高因为相机返回、相册返回、裁剪返回三个结果码可能落在同一个 Activity 里处理顺序一旦被打乱裁剪图的 Uri 就丢了。这里我把整个图像流程集中在 MainActivity 做一次转发把 requestCode、resultCode、data 原样交给 ImageBridge.onActivityResult()。好处是以后新增“拍视频”“选文件”功能时MainActivity 一行不用改只扩展 ImageBridge 内部即可。// MainActivity.java 中只保留转发逻辑 Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); ImageBridge.onActivityResult(requestCode, resultCode, data); }// ImageBridge.java 中的分发逻辑骨架 public static void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode ! Activity.RESULT_OK) { emitToJs(pendingCallbackId, {\code\:-1,\msg\:\user canceled\}); return; } switch (requestCode) { case REQUEST_CAMERA: // 拍照返回从 cameraOutUri 读取不信任 data.getData() break; case REQUEST_GALLERY: // 相册返回data.getData() 是真实图片地址 break; case REQUEST_CROP: // 裁剪返回从 cropOutUri 读取 break; } }注意失败分支也要回调。很多工程只处理成功路径用户取消后 JS 侧 Promise 永远挂起头像按钮像死了一样。我后来在每次 startActivityForResult 之前先把 pendingCallbackId 备份到局部变量onActivityResult 的 RESULT_CANCELED 分支里用它回发 code-1 的结构体JS 侧 catch 到一个明确的取消原因。这个习惯帮我省掉了大量“为什么点了没反应”的排查时间。3. 相机、相册到裁剪Intent 参数与 FileProvider 配置要点3.1 拍照链路FileProvider、EXTRA_OUTPUT 与临时文件回收拍照实现比相册复杂主要在 URI 授权上。Android 7.0 之后系统禁止应用把 file:// 直接放进 Intent否则立刻抛 FileUriExposedException。标准做法是 FileProvider 生成 content:// URI同时对目标应用授权让系统相机可以把照片写进我们指定的临时文件。临时文件我放在 getExternalCacheDir() 下的 image/ 目录因为这是应用自己的缓存目录不需要存储权限也天然避开了 Android 13 的存储权限限制。FileProvider 注册有两处配置少一处都会导致调用相机时崩溃!-- AndroidManifest.xml 中Application 节点内注册 -- 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!-- res/xml/file_paths.xml声明允许被分享的路径 -- paths external-cache-path nameexternal_cache pathimage// /paths这里最容易翻车的是 path 对应关系。external-cache-path 对应 getExternalCacheDir()cache-path 对应 getCacheDir()external-path 对应 getExternalFilesDir()。我见过把临时图片写在 getExternalCacheDir() 却在 file_paths.xml 里配了 cache-path 的工程真机上相机一回调就崩错误信息指向 SecurityException排查半天才发现是路径标签配错。authorities 里的 ${applicationId}.fileprovider 会自动替换成你工程的包名如果你在代码里硬编码了一个和包名不一致的字符串也会出现授权失败。拍照代码的完整形态如下private static Uri cameraOutUri; public static void openCamera(String callbackId, String fileName) { pendingCallbackId callbackId; Cocos2dxActivity activity (Cocos2dxActivity) Cocos2dxActivity.getContext(); File dir new File(activity.getExternalCacheDir(), image); if (!dir.exists()) { dir.mkdirs(); // 目录不存在时FileProvider 授权会失败 } File file new File(dir, fileName); cameraOutUri FileProvider.getUriForFile( activity, activity.getPackageName() .fileprovider, file); Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); intent.putExtra(MediaStore.EXTRA_OUTPUT, cameraOutUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); activity.startActivityForResult(intent, REQUEST_CAMERA); }两个参数值得单独说。EXTRA_OUTPUT 决定照片写入的绝对位置必须和 FileProvider 授权的 Uri 一致如果不加这个参数系统相机返回的 data.getData() 是缩略图不是原图很多开发者栽在这里。FLAG_GRANT_WRITE_URI_PERMISSION 是给系统相机的写权限只加 READ 不加 WRITE 时部分厂商相机会在保存时静默失败拍出来是 0 字节文件JS 层收到返回却读不到有效图片。3.2 相册链路ACTION_GET_CONTENT 与系统裁剪 Intent相册选择我用 ACTION_GET_CONTENT 而不是 ACTION_PICK理由是前者返回的 content:// Uri 可被裁剪 Intent 继续消费跨应用读取也稳定。ACTION_PICK 在部分 ROM 上返回的可能是 file:// UriAndroid 7 下直接作为裁剪输入会抛异常。public static void openGallery(String callbackId) { pendingCallbackId callbackId; Cocos2dxActivity activity (Cocos2dxActivity) Cocos2dxActivity.getContext(); Intent intent new Intent(Intent.ACTION_GET_CONTENT); intent.setType(image/*); intent.addCategory(Intent.CATEGORY_OPENABLE); activity.startActivityForResult(intent, REQUEST_GALLERY); }拿到相册 Uri 后进入裁剪。系统裁剪是隐式 Intentaction 固定为 com.android.camera.action.CROP几乎所有国产 ROM 都自带但行为差异极大。我的参数设计原则aspectX/aspectY 控制裁剪框比例outputX/outputY 控制输出像素尺寸scale 和 return-data 按兼容需要设置。private static Uri cropOutUri; public static void startCrop(Uri sourceUri, String callbackId) { pendingCallbackId callbackId; Cocos2dxActivity activity (Cocos2dxActivity) Cocos2dxActivity.getContext(); File outFile new File(activity.getCacheDir(), crop_ System.currentTimeMillis() .jpg); cropOutUri FileProvider.getUriForFile(activity, activity.getPackageName() .fileprovider, outFile); Intent intent new Intent(com.android.camera.action.CROP); intent.setDataAndType(sourceUri, image/*); intent.putExtra(crop, true); // 启用裁剪框 intent.putExtra(aspectX, 1); // 裁剪框宽比例 intent.putExtra(aspectY, 1); // 裁剪框高比例 intent.putExtra(outputX, 720); // 输出图片宽 intent.putExtra(outputY, 720); // 输出图片高 intent.putExtra(scale, true); // 输出图按比例缩放 intent.putExtra(return-data, false); // 不通过 data 回传写文件 intent.putExtra(MediaStore.EXTRA_OUTPUT, cropOutUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); activity.startActivityForResult(intent, REQUEST_CROP); }outputX/outputY 不建议设置太大。720 在头像场景足够清晰但 1920 的输出会让裁剪后的 Bitmap 直接吃十几 MB 内存。return-datafalse 的优先级更高它让裁剪器把结果写进 cropOutUri而不是通过 Intent 的 data 字段返回。部分 ROM 在 return-datatrue 时返回的 data 为 null 或是一个缩略图这也是下面避坑章要专门讲的问题。输出目录放在 activity.getCacheDir()生命周期短也不污染用户相册。3.3 裁剪结果读取先采样再落盘别直接怼原图onActivityResult 回到 REQUEST_CROP 分支后核心动作是从 cropOutUri 读取 Bitmap。我的固定流程第一步用 BitmapFactory.Options 的 inJustDecodeBounds 读宽高第二步计算 inSampleSize 做采样压缩第三步 decode 后判断旋转最后一步用 85% 的 JPEG 质量写回文件。如果绕过采样直接 decodeOOM 只是时间问题。public static Bitmap decodeSampledBitmap(Uri uri, int reqWidth, int reqHeight) { BitmapFactory.Options boundsOptions new BitmapFactory.Options(); boundsOptions.inJustDecodeBounds true; // 只读边界不加载像素 BitmapFactory.decodeStream(openInputStream(uri), null, boundsOptions); int sampleSize 1; while (boundsOptions.outWidth / sampleSize reqWidth * 2 || boundsOptions.outHeight / sampleSize reqHeight * 2) { sampleSize * 2; // 2 的幂次保证解码效率 } BitmapFactory.Options decodeOptions new BitmapFactory.Options(); decodeOptions.inSampleSize sampleSize; // 采样率 decodeOptions.inPreferredConfig Bitmap.Config.RGB_565; return BitmapFactory.decodeStream(openInputStream(uri), null, decodeOptions); }采样率按 2 的幂次翻倍因为 inSampleSize 只有 2 的幂次才有效while 条件里的 reqWidth * 2 是经验值避免最终 Bitmap 因为恰好卡在边界而丢失过多细节。RGB_565 在头像场景足够RGBA_8888 会多占一倍内存。这里有个细节decodeStream 需要两次打开输入流第一次读边界第二次读像素同一个流不能复用。很多初学者把两个步骤共用同一个 InputStream第二次 decode 返回 null图片永远是黑屏。裁剪后写入临时文件给上传模块用的代码public static File saveBitmapToCache(Bitmap bitmap) { Cocos2dxActivity activity (Cocos2dxActivity) Cocos2dxActivity.getContext(); File file new File(activity.getCacheDir(), upload_ System.currentTimeMillis() .jpg); try (FileOutputStream fos new FileOutputStream(file)) { // 第三个参数 JPEG 压缩质量0-10085 是画质与体积的平衡点 bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fos); fos.flush(); } catch (IOException e) { e.printStackTrace(); } return file; }85 这个值在大多业务场景下没有人眼可感知的画质损失体积却比 100 质量下降 40% 左右。如果想留出上传带宽余量可以降到 75但再低就可能在浅色背景上看到明显色块。4. 裁剪图上传与下载Multipart 与 MediaStore 两条实现路线4.1 上传Multipart 表单流式提交拒绝 base64 投机上传头像时很多教程让 JS 端把图片转 base64 再 POST小图场景没问题但裁剪后的 1024x1024 JPEG 转成 base64 后体积膨胀约 33%加上 JSON 包装和 URL 编码很容易 413。我这边统一用 OkHttp 的 MultipartBody 流式上传Java 层直接读本地临时文件不经过 JS 层内存占用稳定。public static void uploadAvatar(String callbackId, String uploadUrl, String token) { // 上传是耗时操作必须切子线程否则阻塞 GL 线程 new Thread(new Runnable() { Override public void run() { OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) // 建连超时 .readTimeout(30, TimeUnit.SECONDS) // 读流超时 .writeTimeout(30, TimeUnit.SECONDS) // 写流超时 .retryOnConnectionFailure(true) // 连接失败重试 .build(); Request request new Request.Builder() .url(uploadUrl) .post(new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart(token, token) // 业务鉴权 .addFormDataPart(file, avatar.jpg, RequestBody.create( MediaType.parse(image/jpeg), uploadFile)) // 3.3 生成的上传文件 .build()) .build(); try (Response response client.newCall(request).execute()) { String body response.body().string(); if (response.isSuccessful()) { emitToJs(callbackId, {\code\:0,\msg\:\upload ok\,\data\: body }); } else { emitToJs(callbackId, {\code\:-1,\msg\:\http response.code() \}); } } catch (IOException e) { emitToJs(callbackId, {\code\:-1,\msg\:\ e.getMessage() \}); } } }).start(); }两个容易忽略的参数。connectTimeout 和 readTimeout 必须分开设置移动网络下建连正常但读流很慢只设总超时分会误伤大图上传。retryOnConnectionFailure(true) 只对连接阶段的重试有效不会在服务端已接收请求后重复发同一文件可以放心开。MultipartBody.Builder 里 addFormDataPart 的 file 字段名要和后端约定一致我见过前后端字段名一个叫 file 一个叫 image 导致签名校验失败这类问题在接口联调阶段就要固定下来。上传进度如果产品要求可以包一层 RequestBody在 writeTo 里按已写字节数回调 JS。4.2 下载OkHttp 拉流 MediaStore 落盘兼容 Android 10 分水岭头像下载的难点在存储权限的分版本差异。Android 9 及以下可以直接写外部存储Android 10 之后推荐用 MediaStore应用在不需要存储权限的情况下把文件放进系统媒体库。两种方式都保留按 SDK_INT 分支处理。Android 10 的写法public static void downloadAvatar(String callbackId, String fileUrl, String fileName) { new Thread(new Runnable() { Override public void run() { Cocos2dxActivity activity (Cocos2dxActivity) Cocos2dxActivity.getContext(); OkHttpClient client new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); Request request new Request.Builder().url(fileUrl).build(); try (Response response client.newCall(request).execute()) { if (!response.isSuccessful()) { emitToJs(callbackId, {\code\:-1,\msg\:\download http response.code() \}); return; } byte[] bytes response.body().bytes(); if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 10MediaStore 写入公共下载目录 ContentValues values new ContentValues(); values.put(MediaStore.Downloads.DISPLAY_NAME, fileName); values.put(MediaStore.Downloads.MIME_TYPE, image/jpeg); values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS / activity.getPackageName()); Uri uri activity.getContentResolver().insert( MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); try (OutputStream os activity.getContentResolver().openOutputStream(uri)) { os.write(bytes); } emitToJs(callbackId, {\code\:0,\msg\:\saved\,\path\:\ uri \}); } else { // Android 9 及以下直接写公共 Download 目录 File dir Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS); File file new File(dir, fileName); try (FileOutputStream fos new FileOutputStream(file)) { fos.write(bytes); } emitToJs(callbackId, {\code\:0,\msg\:\saved\,\path\:\ file.getAbsolutePath() \}); } } catch (IOException e) { emitToJs(callbackId, {\code\:-1,\msg\:\ e.getMessage() \}); } } }).start(); }RELATIVE_PATH 是 Android 10 新增字段必须配合 MIME_TYPE 使用否则部分 ROM 的下载列表不显示。注意这里没有申请存储权限因为 MediaStore.Downloads 在应用自身作用域内不需要权限如果要写公共 Pictures 目录Android 11 之后同样可以用 MediaStore 规避权限但读其他应用创建的图片仍需 READ_MEDIA_IMAGES。Android 9 及以下分支需要 Manifest 配置 WRITE_EXTERNAL_STORAGE 并运行时动态申请。下载文件用 bytes() 一次性读进内存再落盘头像体积小没问题如果是十几 MB 的视频封面或原图应改 byteStream() 边读边写避免大文件打爆内存。4.3 返回约定JSON 结构体与 Creator 端解析原生层和 JS 层之间我用统一的 JSON 结构作为通信协议。成功{code:0,msg:成功信息,path:本地或网络路径}失败{code:-1,msg:错误信息}。为什么不用裸字符串因为相册取消、裁剪失败、上传断网、下载权限拒绝四种失败场景的错误语义完全不同JS 端要据此决定弹 toast 还是静默恢复必须有稳定字段。// Creator 侧统一的原生调用入口与结果解析 const NativeBridge { callbacks: {}, invoke(method, ...args) { return new Promise((resolve, reject) { const id cb_ Date.now() _ Math.random().toString(36).slice(2, 8); this.callbacks[id] (jsonStr) { const data JSON.parse(jsonStr); if (data.code 0) { resolve(data); } else { reject(data); } }; // 回调 ID 放在参数末尾与 Java 侧签名保持一致 args.push(id); jsb.reflection.callStaticMethod( com/demo/bridge/ImageBridge, method, (Ljava/lang/String;Ljava/lang/String;)V, ...args ); }); } }; // 使用示例打开相册 - 裁剪 - 拿本地路径 NativeBridge.invoke(openGallery) .then((data) { cc.log(裁剪完成本地路径, data.path); }) .catch((err) { cc.log(用户取消或操作失败, err.msg); });回调 ID 在 JS 侧生成作为普通参数传给 JavaJava 的 emitToJs 原样拼接这个 ID 构造脚本。这里只展示 openGallery 的形态上传、下载方法在完整工程里按同样模式扩展签名即可。Promise 化的好处是业务代码不用嵌套回调选择、裁剪、上传三个环节可直接链式串联出错时统一走 catch可读性比回调嵌套高一个量级。5. 避坑实录相机回调丢图、data 为 null、OOM 与权限反复弹窗5.1 拍照返回后图片不存在文件大小为 0现象真机上相机正常弹出正常拍照确认onActivityResult 也执行了但读取指定路径时文件不存在或长度为 0。原因FileProvider 的 authorities 和 file_paths.xml 的标签不匹配最常见其次是部分 ROM 的系统相机在写入前先校验 Uri 写权限Manifest 里只加了 READ 没加 WRITE再一种情况是把输出文件放到了 getFilesDir()却只配了 cache-path导致写入时 URI 指向未授权路径相机选择静默放弃写入。解决先确权再调相机。openCamera 里统一用 FileProvider.getUriForFile 生成 UriaddFlags 同时携带 READ 和 WRITE 权限file_paths.xml 的标签与目录一一对应。拍完在 onActivityResult 里先 file.exists() 和 file.length() 双重判断不满足条件直接回调 -1不要等上传时再报错。这个前置检查在联调阶段筛掉了大量机型兼容问题。5.2 裁剪返回 data 为 null图片拿不到现象相册选图正常进入裁剪裁剪完成后 onActivityResult 里 data.getData() 是 nulldata 本身也可能是 null。原因部分国产 ROM 的系统裁剪器在 return-datatrue 时不回传数据或只回传一个缩放后的缩略图也有 ROM 的裁剪器根本不支持向 Intent 的 data 字段写结果。解决把 return-data 设为 false预先通过 MediaStore.EXTRA_OUTPUT 指定输出 path从 cropOutUri 读结果。cropOutUri 的生成同样走 FileProvider如果裁剪器不认 content:// 而认 file://再回退到 Uri.fromFile 并配合 FileUriExposedException 处理。我踩过最深的坑是某款手机上裁剪器既不写 cropOutUri 也不返回 data最后查 logcat 发现它把结果写进了自身缓存目录于是改成 startActivityForResult 前先检查系统是否存在可用裁剪器不存在就跳过裁剪直接压缩原图。5.3 上传大图 OOM 或服务端 413现象小图测试一切正常一旦用户从相册选了一张 12MP 的原图APP 直接崩或者上传成功后服务端报 413 Request Entity Too Large。原因decode 阶段没有 inSampleSizeBitmap 以原始分辨率加载例如 4032x3024 的 ARGB 图占用接近 48MB 内存base64 再把体积放大 33%超出服务端请求体上限。解决在 Java 层强制做两步压缩。先采样到目标宽高再加载像素采样算法见 3.3 的 decodeSampledBitmap再以 JPEG 85% 质量写文件。两步顺序不能反。如果原图本身就是 JPEG先采样再压质量体积能控在 200KB 以下。413 是另一个维度的问题需要在请求头让后端把 body 上限提到 2MB或者在 JS 层把图片窄边压缩到 720px 再上传。要求保留原图时选后者纯头像场景选前者。5.4 权限弹窗反复出现Android 13 上相册读取被拒现象Android 13 目标版本下明明在 Manifest 里声明了 READ_EXTERNAL_STORAGE请求权限时却被系统忽略Android 11 设备上弹了两三次权限框选了始终允许还会再弹。原因Android 13 把媒体读取权限拆成了 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO 三类Android 11 的“仅此次允许”选项也会让同一权限多次弹窗只有“始终允许”才是一次性。解决Manifest 按 targetSdkVersion 分支声明。33 及以上声明 READ_MEDIA_IMAGES11~32 声明 READ_EXTERNAL_STORAGE10 及以下声明 WRITE_EXTERNAL_STORAGE。运行时权限同理先判断 SDK_INT 再决定请求哪一组。如果只是用 FileProvider MediaStore 链路多数场景根本不需要这些权限拍照走 cache 目录、下载走 MediaStore都绕过了权限墙。真正需要权限的是读用户相册此时正确姿势是 JS 层先拿 activity 实例再在 Java 层 requestPermissions回调结果统一转给 JS不要依赖 OS 默认弹窗。5.5 回调 ID 重复前一次操作被后一次覆盖现象用户快速连点两次“选择头像”第一次的裁剪结果回来时调用的却是第二次的回调第一次的 Promise 永远等不到 resolve。原因JS 侧回调 ID 用固定字符串或秒级时间戳时连点场景下第二次操作把 window[cb_xxx] 覆盖了Java 侧的 pendingCallbackId 是单字段也被第二次请求覆写。解决JS 侧用 Date.now() 随机串生成 ID保证毫秒级不重复Java 侧不用单字段 pendingCallbackId改用 MaprequestCode, callbackId 管理onActivityResult 时按 requestCode 取回对应 ID。连点保护我习惯在 JS 层也加一道上一次操作未完成时直接忽略新的请求。这样即使原生层没问题UI 层也不会出现两个相机界面叠加的状态。一次内测里见过连点导致两个裁剪界面同时弹出加了 JS 层锁之后彻底消失。6. 把整套流程固化成通用组件Promise 封装与真机验证清单6.1 极简组件封装把各章代码组装成可复用组件。JS 侧扩展一个头像选择器对外只暴露 chooseAvatar()const AvatarPicker { chooseAvatar(size 720) { return NativeBridge.invoke(openCamera) .catch(() { // 用户取消相机回退到相册 return NativeBridge.invoke(openGallery); }) .then((data) { // 上传成功后刷新头像 this.refresh(data.url || data.path); return data; }); } };相机取消时 catch 再走相册是产品验收时说“取消相机别白屏”之后加的兜底。size 参数要透传到 Java 层作为裁剪输出尺寸实现一行改动适配不同场景。6.2 真机验证清单改完桥接代码后我会过一遍以下验证列表只在模拟器上跑不够因为 FileProvider、Intent 冲突、ROM 裁剪器差异只在真机暴露验证项操作步骤期望结果桥接探针执行 greet 最小示例日志输出 hello,creator拍照带裁剪openCamera 后进入裁剪返回本地路径文件大于 0 字节相册取消openGallery 后返回JS 收到 code-1上传成功选图后上传服务端收到文件无 413下载落盘下载头像Android 10 图库可见连点保护快速点两次选择头像只弹一个选择界面6.3 一个说烂了但我仍要重复的教训EXIF 方向试过某款低端安卓机横拍头像相册里看正常上传后却是旋转 90 度。查 logcat 才发现系统相机在 JPEG 里写入了 EXIF 方向标记而 BitmapFactory 解码时完全不理会。从那以后每个项目的图片链路都强制走一遍“读 EXIF - 按 orientation 旋转 - 再压缩 - 再上传”固定用 ExifInterface 读取 TAG_ORIENTATION再按方向做 matrix 旋转。这个坑做上传头像的人都会踩区别只是踩得早还是晚。希望这篇笔记能帮你省掉爬坑时间也希望你调通后会发现桥接层没那么神秘把链路拆开每一条命令的参数都弄明白剩下的就是耐心排查。拿到这套 Creator 相机相册工程后先跑一遍验证清单再上业务会比直接改业务代码稳得多。希望帮到你。本文还有配套的精品资源点击获取