Cocos Creator 调用 Android 相机相册:桥接、裁剪、上传与缓存全解析
简介面向Android与Cocos Creator开发者的图片交互实战资源聚焦调用系统相机与相册、裁剪图片并完成上传下载的完整链路适合需要实现头像上传、图片采集等功能的初中级开发者参考。资源包共321个文件约5.12MB以json配置、rawproto与meta数据、js脚本、jar与aar依赖库为主辅以png图片、xml布局、gradle构建文件及少量java源码覆盖工程配置、依赖集成与运行产物等环节。已有2446人学习下载说明该流程在实际开发中需求普遍。内容围绕权限声明与运行时申请、ACTION_IMAGE_CAPTURE与ACTION_PICK启动相机相册、onActivityResult接收结果、系统裁剪Intent设置宽高比与输出尺寸、Bitmap压缩保存为本地文件以及基于OkHttp的Multipart上传与按URL下载头像等关键点展开并提及AvatarManager对流程的封装思路便于读者理解模块划分与代码组织方式快速搭建稳定可复用的图片处理模块。1. 调用系统相机相册这件事Cocos Creator 里没你想的那么顺手做 Cocos Creator 项目第一次碰到「上传头像」需求时很多人会下意识去找引擎自带的相机 API翻完文档才发现Creator 本身并不提供跨平台的相机、相册调用能力它只负责渲染和逻辑真正干活的是各平台的原生层。于是问题就变成了——怎么在 Creator 的 JS/TS 逻辑里把 Android 的相机、相册、裁剪、上传、下载这一整条链路串起来。这份资源围绕的正是这个场景在 Creator 项目里调用 Android 系统相机与相册拿到图片后走系统裁剪再把裁剪结果上传到服务端同时支持头像的下载与本地缓存。适合正在做用户中心、个人资料页、社交类 Demo 的开发者尤其是被「JS 调不动原生」卡住过一轮的人。下面按能复现的顺序拆开讲。2. 打通 JS 与 Android 原生桥接层怎么搭才不返工Creator 跑在 Android 上时JS 逻辑最终由引擎的 JSB 绑定层转发到 Java/Kotlin。想让 JS 触发相机核心是两件事一是 JS 侧能调用到原生静态方法二是原生处理完结果后能把数据回传给 JS。这条链路如果一开始设计得随意后面加裁剪、加上传会反复改所以桥接层要一次搭对。2.1 用反射调用原生静态方法的标准写法Creator 提供了jsb.reflection.callStaticMethod这是 JS 侧调用 Java 静态方法的入口。它的签名是「类名 方法名 方法签名 参数」方法签名必须严格匹配参数类型写错一个字符就会静默失败这是最常见的翻车点。// 调用 Android 原生打开相机 // 参数1完整类名含包路径 // 参数2静态方法名 // 参数3方法签名() 内是参数类型后面是返回值类型 // 参数4 起实际传入的参数 if (cc.sys.os cc.sys.OS_ANDROID) { jsb.reflection.callStaticMethod( com/example/creatorcam/MediaBridge, // 类名用斜杠分隔 openCamera, ()V, // 无参、无返回值 null ); }这里()V表示方法不接收参数、返回 void。如果原生方法需要接收一个字符串比如保存路径签名要写成(Ljava/lang/String;)V接收 int 则是(I)V。类名必须用/而不是.这是 JSB 反射的硬性约定写成点号会直接抛「class not found」。我一般会在桥接类里把所有对外方法集中放方法名统一加前缀避免和引擎内部或其他插件的方法撞名。2.2 原生侧接收调用并拉起系统相机Java 侧要提供一个 public static 方法内部通过当前 Activity 启动系统相机 Intent。难点在于「当前 Activity」怎么拿——Creator 的 Activity 不是你自己 new 出来的需要通过引擎提供的上下文获取。package com.example.creatorcam; import android.app.Activity; import android.content.Intent; import android.provider.MediaStore; import org.cocos2dx.lib.Cocos2dxHelper; public class MediaBridge { private static final int REQ_CAMERA 1001; // JS 调用的入口必须是 public static public static void openCamera() { Activity activity Cocos2dxHelper.getActivity(); Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); // 指定输出到临时文件避免拿到的是缩略图 intent.putExtra(MediaStore.EXTRA_OUTPUT, MediaStoreUtils.createTempImageUri(activity)); activity.startActivityForResult(intent, REQ_CAMERA); } }Cocos2dxHelper.getActivity()是引擎暴露的上下文入口不同 Creator 版本类名可能略有差异常见做法是先确认工程里引擎 so 对应的 Java 包再决定 import 哪个类。EXTRA_OUTPUT这一行是关键不指定输出路径时系统相机返回的data里只有一张压缩过的缩略图分辨率低到没法当头像用这是新手最容易踩的坑。指定输出 URI 后原图会写到该路径onActivityResult里再读文件即可。2.3 相册选择与 onActivityResult 回传相册走的是ACTION_PICK或ACTION_GET_CONTENT前者直接进图库后者会弹选择器。头像场景一般用ACTION_PICK更直接。public static void openGallery() { Activity activity Cocos2dxHelper.getActivity(); Intent intent new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); activity.startActivityForResult(intent, REQ_GALLERY); }结果回调必须落在 Activity 的onActivityResult里。Creator 工程里通常有一个继承自Cocos2dxActivity的主 Activity把回调逻辑写进去或者用一个转发类在onResume阶段注册监听。拿到resultCode RESULT_OK后从intent.getData()取相册 URI或从相机约定的输出路径读文件然后统一转成可裁剪的输入。回传给 JS 有两种方式一是通过Cocos2dxHelper.runOnGLThread里调用evalString执行一段 JS把 base64 或文件路径塞进去二是把结果写到固定路径JS 侧轮询或等一个原生回调事件。前者实时性好后者实现简单。我一般用evalString触发一个全局 JS 函数参数用 JSON 字符串避免转义地狱。// 原生处理完图片后回传路径给 JS String js String.format(window.onNativeImageReady window.onNativeImageReady(%s), path); Cocos2dxHelper.runOnGLThread(() - Cocos2dxHelper.evalString(js));runOnGLThread不能省JS 执行必须在引擎线程上直接在原生回调线程里 eval 会崩。路径里的反斜杠和引号要提前处理否则拼出来的 JS 语法错误表现为「回调没反应」排查起来很费时间。3. 系统裁剪与图片压缩尺寸、格式、内存三笔账拿到原图只是第一步头像场景对尺寸和体积有硬要求。系统裁剪能省掉自己写裁剪 UI 的成本但它的行为在不同 Android 版本上并不完全一致参数没设对就会出现「裁完还是原图」「裁完糊成马赛克」这类问题。3.1 用系统裁剪 Intent 控制输出尺寸Android 的系统裁剪通过com.android.camera.action.CROP触发虽然它不是官方文档里的标准 Action但在绝大多数设备上可用。关键参数是aspectX/aspectY裁剪框比例、outputX/outputY输出像素和return-data。public static void cropImage(Activity activity, Uri sourceUri, int outSize) { Intent intent new Intent(com.android.camera.action.CROP); intent.setDataAndType(sourceUri, image/*); intent.putExtra(crop, true); intent.putExtra(aspectX, 1); // 头像固定 1:1 intent.putExtra(aspectY, 1); intent.putExtra(outputX, outSize); // 建议 512 或 1024 intent.putExtra(outputY, outSize); intent.putExtra(scale, true); intent.putExtra(return-data, false); // 大图不要走 data 回传 intent.putExtra(MediaStore.EXTRA_OUTPUT, MediaStoreUtils.createTempImageUri(activity)); intent.putExtra(outputFormat, JPEG); activity.startActivityForResult(intent, REQ_CROP); }return-data设为 true 时裁剪结果会塞进 Intent 的 extras 里以 Bitmap 形式返回但 Intent 传输有大小限制约 1MB 量级超过就抛TransactionTooLargeException或者直接返回空。头像输出 512×512 的 JPEG 通常没问题但为了稳我一般设 false 并指定EXTRA_OUTPUT让结果落盘再读。outputX/outputY不要设成 4096 这种大值部分机型裁剪后会 OOM512 到 1024 对头像足够。3.2 压缩与格式选择别让头像拖慢上传裁剪完的图还要过一道压缩。JPEG 适合照片PNG 适合带透明通道的图头像一般用 JPEG质量 80 到 90 之间肉眼几乎无差体积能降一半以上。public static String compressToFile(Bitmap bitmap, File outFile, int quality) { try (FileOutputStream fos new FileOutputStream(outFile)) { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos); fos.flush(); return outFile.getAbsolutePath(); } catch (IOException e) { return null; } }quality参数是 0 到 100注意它只对 JPEG 有效PNG 会忽略。压缩前要按目标尺寸做一次Bitmap.createScaledBitmap不要指望compress帮你缩放它只改编码质量不改分辨率。内存上一张 4000×3000 的原图解码成 ARGB_8888 约占 48MB低端机直接崩所以解码时用inSampleSize先降采样再缩放这是控制内存的标准两步。3.3 上传前的数据组织base64 还是 multipart上传方式影响服务端接口设计和传输效率。base64 把图片编码成字符串塞进 JSON实现简单但体积膨胀约 33%且大图容易触发请求体上限multipart/form-data 是二进制直传效率高适合头像这种单文件场景。// Creator 侧用 XMLHttpRequest 发 multipart function uploadAvatar(filePath, uploadUrl, token) { const xhr new XMLHttpRequest(); xhr.open(POST, uploadUrl, true); xhr.setRequestHeader(Authorization, Bearer token); const form new FormData(); // 原生侧把文件路径回传后用引擎的文件系统读成 blob const data jsb.fileUtils.getDataFromFile(filePath); const blob new Blob([new Uint8Array(data)], { type: image/jpeg }); form.append(avatar, blob, avatar.jpg); xhr.onload () { if (xhr.status 200) { const res JSON.parse(xhr.responseText); console.log(上传成功返回地址:, res.url); } }; xhr.send(form); }getDataFromFile返回的是 ArrayBuffer 类数据包成Uint8Array再进 Blob顺序不能反。FormData的第三个参数是文件名服务端靠它判断扩展名漏了可能被当成无类型文件拒收。token 放 header 而不是 query避免日志泄露。上传超时和失败重试要自己加xhr.timeout设个 15 秒onerror里做一次退避重试头像这种小文件重试成本很低。4. 头像下载与本地缓存路径、校验、清理的完整闭环上传解决「存」下载解决「取」。头像下载的诉求不只是拉一张图还要考虑重复请求、离线可用、缓存失效。Creator 里没有现成的图片缓存组件得自己用文件系统加内存字典搭一层。4.1 下载到可写目录并生成稳定文件名下载目标目录要用jsb.fileUtils.getWritablePath()这是各平台保证可写的路径。文件名建议用「用户 ID 版本号」或 URL 的哈希避免同一用户换头像后还命中旧缓存。function downloadAvatar(url, userId, onDone) { const writable jsb.fileUtils.getWritablePath(); // 用 URL 哈希做文件名内容变则文件名变天然规避缓存脏读 const hash simpleHash(url); const savePath ${writable}avatar_${userId}_${hash}.jpg; if (jsb.fileUtils.isFileExist(savePath)) { onDone(savePath); // 命中本地缓存直接返回 return; } const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.responseType arraybuffer; xhr.onload () { if (xhr.status 200) { const ok jsb.fileUtils.writeDataToFile(new Uint8Array(xhr.response), savePath); onDone(ok ? savePath : null); } }; xhr.send(); }responseType必须设arraybuffer默认的文本模式会把二进制图写坏表现为「下载成功但图片打不开」。writeDataToFile接收Uint8Array传错类型会静默返回 false。文件名带哈希的好处是服务端换图后 URL 通常带新参数哈希变、文件名变旧文件自然不再被引用不需要额外的版本比对逻辑。4.2 内存缓存与 SpriteFrame 复用文件缓存解决持久化但每次显示都从磁盘读还是慢。头像列表滚动时反复解码同一张图帧率会掉。常见做法是在内存里维护一个userId - SpriteFrame的字典命中就直接赋给 Sprite。const avatarCache new Map(); function loadAvatarSprite(sprite, userId, url) { if (avatarCache.has(userId)) { sprite.spriteFrame avatarCache.get(userId); return; } downloadAvatar(url, userId, (path) { if (!path) return; // 用引擎的 loader 从本地路径加载纹理 cc.assetManager.loadRemote(path, { ext: .jpg }, (err, texture) { if (err) return; const sf new cc.SpriteFrame(texture); avatarCache.set(userId, sf); sprite.spriteFrame sf; }); }); }loadRemote传本地绝对路径时ext要显式给否则引擎可能按 URL 猜扩展名失败。缓存字典要有上限比如超过 50 条就淘汰最久未用的否则长时间运行内存只涨不降。淘汰时记得sf.destroy()只从 Map 里删引用不会释放纹理这是 Creator 里很隐蔽的一处泄漏。4.3 缓存失效与磁盘清理策略头像不是一成不变的用户换图后旧缓存要能失效。前面用 URL 哈希做文件名已经解决「新图不命中旧文件」但旧文件会一直堆在可写目录里。清理策略有两种启动时扫一遍目录删掉超过 N 天未访问的或者按用户维度只保留最新一张。function cleanOldAvatars(maxAgeDays) { const dir jsb.fileUtils.getWritablePath(); const now Date.now(); const files jsb.fileUtils.listFiles(dir); files.forEach((name) { if (!name.startsWith(avatar_)) return; const full dir name; const attr jsb.fileUtils.getFileAttributes(full); // 用修改时间近似访问时间超期即删 if (now - attr.modifiedTime * 1000 maxAgeDays * 86400000) { jsb.fileUtils.removeFile(full); } }); }getFileAttributes的modifiedTime单位是秒和 JS 的毫秒要换算漏乘 1000 会导致「刚下载的图被当成过期删掉」。清理放在启动后延迟几秒执行别和首屏加载抢 IO。这套组合下来头像的「上传—存储—下载—缓存—清理」才算闭环缺任何一环都会在真机上以各种奇怪现象冒出来。5. 避坑清单真机上翻过的那几次车5.1 相机返回缩略图怎么调都是糊的现象拍照后拿到的图只有一两百像素放大全是马赛克。原因启动相机 Intent 时没设EXTRA_OUTPUT系统默认只回传缩略图放在data里。解决启动前用FileProvider生成一个临时文件 URI通过EXTRA_OUTPUT传进去onActivityResult里从该文件读原图。Android 7.0 以上还必须配FileProvider和file_paths.xml直接用file://URI 会抛FileUriExposedException。5.2 反射调用静默失败日志里什么都没有现象JS 调了callStaticMethod原生方法没进也没报错。原因方法签名写错或类名用了点号或目标方法不是public static。解决签名严格按 JNI 规则写类名用/分隔方法必须是public static调试时先在原生方法第一行打日志确认是否进入再逐步排查参数。签名错误在部分引擎版本里不抛异常只返回 undefined只能靠日志定位。5.3 裁剪后 OOM低端机必崩现象选完大图进裁剪页直接闪退。原因原图分辨率过高解码时没降采样单张 Bitmap 占用几十 MB。解决解码前用BitmapFactory.Options的inJustDecodeBounds先读尺寸按目标输出算inSampleSize再真正解码裁剪输出尺寸控制在 1024 以内。这条在 2GB 内存的机器上尤其明显模拟器上往往复现不了。5.4 下载的图打不开文件大小对但内容是乱码现象下载返回 200文件也写成功了但显示不出来。原因xhr.responseType没设成arraybuffer二进制被当文本处理。解决下载请求显式设responseType arraybuffer写入前确认拿到的是ArrayBuffer或Uint8Array。另外检查服务端返回的Content-Type如果是text/html说明请求被重定向到了错误页状态码 200 不代表拿到的是图。5.5 头像换了但界面还是旧的现象用户重新上传后列表里显示的还是老图。原因内存缓存按 userId 命中没感知到 URL 变化或磁盘文件名没带版本覆盖写失败。解决缓存 key 用「userId URL 哈希」URL 变则 key 变上传成功后主动清掉该 userId 的内存缓存条目并触发刷新。别指望SpriteFrame自己感知文件变化它不会。6. 把整条链路收进一个可复用的封装前面几章是拆开讲的真到项目里相机、相册、裁剪、上传、下载这五步应该收进一个模块对外只暴露几个语义化方法调用方不关心底层是 Intent 还是反射。我一般的封装形态是一个AvatarService内部维护状态机idle - picking - cropping - uploading - done任何一步失败都回到idle并回调错误码避免用户连点导致多个 Intent 叠加。// 对外只暴露三个方法内部串起整条链路 const AvatarService { // 从相机或相册选图自动进入裁剪 pick(source, onResult) { const bridge com/example/creatorcam/MediaBridge; const method source camera ? openCamera : openGallery; jsb.reflection.callStaticMethod(bridge, method, ()V, null); // 原生裁剪完成后通过全局回调把最终路径交回来 window.onNativeImageReady (path) { window.onNativeImageReady null; // 用完即弃防重复触发 onResult(null, path); }; }, // 上传并返回服务端地址 upload(path, onResult) { uploadAvatar(path, API.avatarUpload, getToken(), onResult); }, // 按用户加载头像到 Sprite load(sprite, userId, url) { loadAvatarSprite(sprite, userId, url); } };window.onNativeImageReady这种全局回调用完必须置空否则第二次选图时旧闭包还在会触发两次回调表现为「上传了两遍」或者「界面闪一下又变回去」。状态机那层建议加一个busy标志picking期间忽略新的pick调用这是防连点最省事的做法。验证这套封装是否可靠我习惯在真机上走一遍固定清单冷启动后首次拍照、拒绝相机权限后再授权、选一张 4000 像素以上的大图、弱网下上传中断再重试、换账号后头像是否串号、杀进程重进后缓存是否还在。这几项里最容易漏的是「拒绝权限后再授权」——很多实现第一次拒绝后就不再弹系统授权框用户以为坏了。权限请求要在每次pick前检查未授权则重新申请而不是只在启动时申请一次。还有一个容易被忽略的点裁剪 Intent 在部分定制系统上不存在对应 ActivitystartActivityForResult会抛ActivityNotFoundException。稳妥做法是try/catch包住捕获后降级为自己用 Canvas 做简易裁剪或者直接跳过裁剪只做缩放。降级路径平时用不到但线上机型一杂就会冒出来提前留好这个分支比事后热修省心得多。从那以后我每次接这类「JS 调原生」的需求都会先把桥接层的方法签名、线程切换、回调置空这三件事在最小 Demo 里跑通再往上叠业务逻辑因为这三处一旦出问题现象都是「没反应」排查成本远高于一开始多花半小时验证。希望帮到你。本文还有配套的精品资源点击获取