简介面向Android开发者的相机相册调用与头像裁剪上传下载示例工程重点解决Creator项目中需要调起系统相机/相册、完成图片裁剪并将结果上传下载的常见需求。内容覆盖权限声明与动态请求、Intent启动相机和相册、系统裁剪Bitmap保存、OkHttp上传与下载等关键环节并给出AvatarManager类的封装思路便于直接参考集成到自己的头像模块中。压缩包约5.12MB共321个文件以json、rawproto、meta等配置数据文件java、js、gradle等源码构建文件以及jar、aar、apk等打包产物为主并含png图片与txt说明文档目录结构清晰。目前已有2445人学习下载。通过Demo安装包可直观验证完整流程同时日志与统计相关aar库、构建脚本和工程配置有助于理解依赖关系和集成方式适合初中级Android开发者系统掌握图片选择、裁剪与网络传输。1. 这是一个把「选头像」这件事真正做完整的项目很多游戏或者App的「更换头像」功能做出来的效果都是点到为止能拍照、能从相册选一张然后传上去就完事。但实际业务里头像功能涉及一整条链路——调起Android系统相机、读取系统相册、把图片裁成头像比例、压缩后上传到服务器再把服务器返回的头像URL下载并显示到界面上。这个项目就是把这条链路完整打通而且是在 Cocos Creator 引擎里通过 JSB 方式直接与 Android 原生层交互而不是用 WebView 或浏览器方案绕过。适合正在做 Cocos Creator 2.x 版本社交类、工具类项目需要原生拍照、相册和上传下载能力的开发者也适合刚开始接触 Creator 原生扩展插件、想搞明白 JSB 反射和 Android 原生代码之间怎么配合的进阶学习者。读完这份资源你能拿到一整套可以照抄的头像选择、裁剪、上传、下载闭环实现。Cocos Creator 做 UI 和业务逻辑很强但涉及系统相机这类强原生能力时就必须打开 JSB 这座桥。这个项目的核心价值在于它把桥搭好了并且把桥两端的坑也填平了——从 Android 7.0 的 FileProvider 兼容到厂家 ROM 的裁剪 Intent 差异都有对应的处理姿势。2. Android 原生层实现相机、相册、裁剪三个入口的封装思路在 Creator 里做原生相机相册功能第一步永远是先在 Android 那边写一个能被 JS 调用的原生入口类。这个项目里把所有原生逻辑收敛在一个工具类中对外只暴露一个带回调的静态方法JS 侧通过反射去调用它。这样做的好处是 Creator 版本升级时只要 JSB 接口不变原生代码就完全不需要动。2.1 相机拍照从 Intent 到 FileProvider 兼容Android 调起系统相机最接近「合格开发」的做法是使用 Intent 加 MediaStore.ACTION_IMAGE_CAPTURE同时要把拍照得到的原图写到我们自己的私有目录文件中而不是让系统返回缩略图数据。很多新手在这里会犯一个经典错误直接在 onActivityResult 里用 data.getParcelableExtra(data) 取图拿到的是只有几十KB的缩略图放大多就糊了。正确的做法是提前创建好一个 File 对象通过 intent.putExtra(MediaStore.EXTRA_OUTPUT, uri) 告诉系统把全尺寸图片写进去之后再去读取这个文件。在 Android 7.0 及以上系统直接用 file:// 类型的 Uri 跨应用传递会直接抛 FileUriExposedException。这里的标准解法是使用 FileProvider在 AndroidManifest.xml 中注册一个 authorities 为「包名.fileprovider」的 provider并在 res/xml/ 下写好 file_paths.xml指定对外暴露的目录路径。这个项目里就是这么处理的代码逻辑如下// 在原生工具类中创建拍照用的 File private static File createImageFile() throws IOException { String timeStamp new SimpleDateFormat(yyyyMMdd_HHmmss, Locale.getDefault()).format(new Date()); File storageDir context.getExternalFilesDir(Environment.DIRECTORY_PICTURES); if (storageDir null) { storageDir context.getFilesDir(); } return File.createTempFile( IMG_ timeStamp _, .jpg, storageDir ); }这段代码指定了图片的存储位置优先使用 getExternalFilesDir 下的 Pictures 目录这个目录属于应用私有外部存储既不需要在运行时申请存储权限又不会污染用户公共相册。createTempFile 这个名字是玄学级的误导它并不会在程序退出时自动删除只会保证文件名唯一前缀加时间戳。这里返回的 File 对象会在 JS 层准备好之后再配合 FileProvider 生成对应的 Uri 传给相机 Intent。紧接着是构建相机 Intent 并携带输出 UriIntent takePictureIntent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (takePictureIntent.resolveActivity(context.getPackageManager()) ! null) { Uri photoURI FileProvider.getUriForFile(context, context.getPackageName() .fileprovider, photoFile); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); // 关键把读写权限授给相机应用 takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); }注意最后那行 addFlags 必不可少。相机是一个独立的系统应用虽然 FileProvider 生成的 Uri 默认带了授权意图但在部分 ROM 上不加这两个 Flag 会直接闪退或者拍完照返回空结果。整个拍照流程的回调集中在 onActivityResult 里这个项目统一走同一个入口分发逻辑根据 requestCode 区分是拍照还是相册。2.2 相册选择和裁剪 Intent兼容国产 ROM 的姿势从相册选图这个项目使用的是 Intent.ACTION_GET_CONTENT这是兼容性最好的方案不需要额外声明读存储权限系统会自动帮我们做授权。打开相册后拿到的是系统返回的 content:// 类型的 Uri不能直接当作文件路径来用。必须通过 ContentResolver 去查询真实路径或者直接把这个 Uri 作为裁剪的输入源。裁剪是头像功能里最容易翻车的环节。系统自带的裁剪 Intentcom.android.camera.action.CROP在 Google 原生系统上表现良好但在某些国产 ROM 上会出现点击无响应、裁完没回调、或者裁剪界面比例错乱的问题。这个项目里做了一个防御性处理先用 try-catch 包裹裁剪 Intent 的创建如果 resolveActivity 返回 null就放弃裁剪直接使用原图仅仅做一个居中缩放显示。private static void performCrop(Uri sourceUri, File outputFile) { try { Intent cropIntent new Intent(com.android.camera.action.CROP); cropIntent.setDataAndType(sourceUri, image/*); cropIntent.putExtra(crop, true); cropIntent.putExtra(aspectX, 1); cropIntent.putExtra(aspectY, 1); cropIntent.putExtra(outputX, 300); cropIntent.putExtra(outputY, 300); cropIntent.putExtra(scale, true); cropIntent.putExtra(return-data, false); cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(outputFile)); cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); // 需要处理 7.0 以后对 file:// Uri 的适配 } catch (ActivityNotFoundException e) { // 降级直接使用 sourceUri不裁剪 } }这里存在一个容易踩的坑文件输出 Uri 不要使用 FileProvider 生成的 Uri而是直接用 Uri.fromFile。裁剪是一个系统组件它与第三方应用鉴权方式不同部分 ROM 的裁剪界面在写文件时需要 file:// 格式才能真正落盘。如果使用 FileProvider Uri反而会在写入阶段报错找不到文件。在实际编码时outputFile 通常会放在缓存目录裁剪完成后立刻从缓存里读取该文件。3. Creator 与 Android 桥接JSB 反射调用与图片回调链路原生层写好了接下来就是最关键的 JSB 桥接环节。Cocos Creator 2.x 的 Android 原生扩展原理本质上是 JS 引擎通过反射机制直接调用 Java 静态方法。在 2.x 版本中最稳妥、不依赖绑定代码生成的方式就是使用 jsb.reflection.callStaticMethod。这个项目的 JS 侧封装就是建立在这个方法之上把原生回调转化为 JS 函数调用。3.1 JS 侧封装从 callStaticMethod 到 Promise 化JS 侧不能直接拿到 Java 方法的返回值来获得图片路径因为相机和相册是异步流程用户要等拍照界面关闭或者选完图片才会回调。因此这个项目采用事件监听模式原生层处理完图片后通过 Cocos 的 JSB 回调向 JS 层发送事件JS 层在 Game 场景中监听这个事件并驱动后续上传逻辑。// CameraAlbumHelper.js 中封装的原生方法调用 const nativeCall (methodName, param) { const args JSON.stringify(param); jsb.reflection.callStaticMethod( org/cocos/creator/camera/CameraAlbumHelper, methodName, (Ljava/lang/String;)V, args ); }; // 将原生回调封装成 Promise调用方不需要关心原生细节 takePhoto() { return new Promise((resolve, reject) { this._resolve resolve; this._reject reject; nativeCall(takePhoto, { requestCode: 1001 }); }); } pickFromAlbum() { return new Promise((resolve, reject) { this._resolve resolve; this._reject reject; nativeCall(pickFromAlbum, { requestCode: 1002 }); }); }这些代码里注意三个细节。第一callStaticMethod 的方法签名格式是「(Ljava/lang/String;)V」意思是一个 String 参数、没有返回值。这个项目的原生方法全部接收 JSON 字符串作为参数是为了规避 JSB 反射在不同 Creator 版本中对多参数和复杂类型的兼容性问题。第二Promise 的 resolve 和 reject 缓存为实例变量等原生回调触发时再调用。第三requestCode 不是 Android 层的那个 forActivityResult 请求码而是业务链路的标识原生层会把它原样透传回来JS 侧据此判断是哪一次请求的回调。3.2 原生回调 JS在 GL 线程上安全地发送事件原生层处理完图片后不能直接在 Java 线程中调用 jsb 相关方法因为 Cocos 的 JS 引擎调度是有线程限制的。必须切换到 GL 线程引擎主线程再向 JS 发送事件。这个项目里用的是 Cocos 2.x 提供的 CocosHelper.runOnGameThread 方法。CocosHelper.runOnGameThread(new Runnable() { Override public void run() { // 通过 Cocos2dxJavascriptJavaBridge 回调 JS 全局函数 Cocos2dxJavascriptJavaBridge.evalString( window.cameraNativeCallback window.cameraNativeCallback( JSONObject.quote(resultJson) ) ); } });evalString 是整个桥接的兜底方案。它不依赖 JS 对象是否预先注册而是直接调用一个全局函数。因为是字符串拼接执行JSON 里的引号必须使用 JSONObject.quote 进行转义否则 JS 端解析直接报语法错误。这里有一个不少开发者踩过的坑直接在非 GL 线程中调用 evalString会导致随机闪退而且非常难复现日志里没有任何异常信息。这个项目的做法是强制 runOnGameThread 一下从根源上规避问题。JS 端的监听代码长这样// Game.js 场景脚本中注册监听 cc.game.on(EVENT_PICTURE_RESULT, (result) { if (result.code 0) { if (result.action crop) { this.uploadAvatar(result.filePath); } else { this.openCrop(result.filePath); } } else { this.showToast(result.message || 操作取消或失败); } }, this);这里的 action 字段是一个简单的状态机take 表示刚拍完照此时只有原图需要再走一遍裁剪流程pick 表示刚选完相册图片同样需要裁剪crop 表示裁剪已经完成得到的是可以直接上传的头像文件。这个设计让 JS 侧的代码只需要写一个统一入口不需要为相机和相册分别维护一套逻辑。4. 上传与下载图片压缩、Base64 传输、服务端存储与回显图片选好且裁剪完毕接下来就是网络传输环节。这个项目在上传前做了一个关键动作把裁剪出来的 Bitmap 进行了一定比例的压缩再转成 Base64 字符串通过 HTTP 接口上传。选择 Base64 方案而不是 multipart 文件流方案主要是考虑到 Creator 的 XMLHttpRequest 对文件流的构造支持比较别扭而 Base64 是无脑可靠的纯文本传输。4.1 图片压缩与 Base64 编码Java 层处理还是 JS 层处理这个项目把压缩逻辑放在了 Java 层理由是 Bitmap 操作本身是 Java 生态强项而且可以在压缩的同时处理掉 EXIF 旋转信息。很多手机拍出来的照片带有方向标记直接用会导致头像在服务端显示为旋转 90 度。解决方式是在裁剪之后读取 JPEG 文件的 EXIF 信息如果 orientation 不等于 0就先旋转再压缩。public static String compressImage(String filePath, int maxWidth, int maxHeight, int quality) { BitmapFactory.Options opts new BitmapFactory.Options(); opts.inJustDecodeBounds true; BitmapFactory.decodeFile(filePath, opts); // 计算采样率避免大图直接 OOM int sampleSize 1; while (opts.outWidth / sampleSize maxWidth * 2 || opts.outHeight / sampleSize maxHeight * 2) { sampleSize * 2; } opts.inJustDecodeBounds false; opts.inSampleSize sampleSize; Bitmap bitmap BitmapFactory.decodeFile(filePath, opts); // 处理 EXIF 旋转 Matrix matrix new Matrix(); int orientation getExifOrientation(filePath); if (orientation ! 0) { matrix.postRotate(orientation); bitmap Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); } ByteArrayOutputStream baos new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos); byte[] bytes baos.toByteArray(); return Base64.encodeToString(bytes, Base64.NO_WRAP); }采样率的判定标准是 2 倍阈值只要图片宽高超过目标尺寸的两倍就降采样一次。这套参数在实际使用中既保证了头像放大后的清晰度又控制了 Base64 字符串长度不至于让 HTTP 请求体过大。quality 参数通常传 85这是图片质量与体积之间比较均衡的一个值。裁剪尺寸为 300x300 时转成 Base64 字符串长度大约在 10KB 到 30KB 之间完全在常规 HTTP 请求合理范围内。JS 侧拿到 Base64 字符串后封装上行接口uploadAvatar(base64Data) { const xhr new XMLHttpRequest(); xhr.open(POST, this.serverConfig.uploadUrl); xhr.setRequestHeader(Content-Type, application/json); xhr.onreadystatechange () { if (xhr.readyState 4 xhr.status 200) { const resp JSON.parse(xhr.responseText); if (resp.code 0) { this.saveAvatarUrl(resp.data.url); } } }; const body JSON.stringify({ uid: this.userId, avatar: base64Data }); xhr.send(body); }实际项目中服务端接口往往是接收 FormData 文件流或者 multipart 请求如果面对的是这类接口JS 层就需要使用 XMLHttpRequest 的 send(FormData) 方式提交二进制内容。这份资源中使用 Base64 JSON 方案是自洽的因为它同时给了服务端最简实现——服务端只需要解析 JSON把 Base64 解码后落盘即可完全不用引入 OSS 或者文件上传中间件。4.2 头像下载与缓存URL 优先、Base64 兜底头像下载同样存在两种场景。第一种是服务端返回一个图片 URLJS 层使用 cc.loader.load 加载远程图片并生成 SpriteFrame 显示第二种是考虑到弱网环境服务端也可能直接返回压缩后的 Base64 字符串。这个项目的 JS 侧在显示头像时做了一个二级判定如果本地有缓存文件优先读本地否则走网络加载网络失败时显示一个默认头像。loadAvatar(url) { if (!url) { this.setDefaultAvatar(); return; } // 先尝试本地缓存 const localPath this.getCachedAvatarPath(); if (localPath) { this.displayImage(localPath); return; } // 网络加载 cc.loader.load({ url: url, type: jpg }, (err, texture) { if (!err texture) { const spriteFrame new cc.SpriteFrame(texture); this.avatarSprite.spriteFrame spriteFrame; // 异步写本地缓存加速二次加载 this.saveToLocal(texture); } else { this.setDefaultAvatar(); } }); }这里的保存到本地缓存使用了一些小技巧对于远程图片写缓存时先转成 base64 再通过原生层的 helper 落盘。不过更简单的做法是直接使用 Creator 自带的 cc.loader 的缓存机制只要 URL 不变二次加载时会自动命中内存缓存。项目中的 saveToLocal 更像是对弱网环境的加强保障。保存缓存文件时记得拼接用户 ID 作为文件名前缀避免不同账号互相覆盖头像这个细节在多人登录的机器上很容易踩坑漏掉。5. 避坑指南权限申请、线程切换、文件清理、回调时序这一章把这套链路里最容易让开发者折腾到深夜的几个问题集中列出来。很多坑在单机测试时完全不会出现一上真机或者一过 Android 版本兼容就原形毕露。下面按「现象 → 原因 → 解决」的方式整理成四组踩坑记录给大家。1. 现象Android 7.0 以上手机拍照后返回应用闪退Logcat 里看到 FileUriExposedException。原因直接使用了 file:// Uri 传给相机 Intent而 Android 7.0 的 StrictMode 禁止应用向其他应用暴露 file:// 类型的 Uri。解决严格按照这个项目的写法使用 FileProvider 替换。注册 authorities 时最好直接以「包名.fileprovider」命名这样每个应用都是唯一的不会与其他应用冲突。file_paths.xml 里的 path 要与实际存储目录相对路径一致否则会报无法解析路径的异常。测试时重点验证两个场景应用有外部存储权限时的路径和应用没有存储权限时降级到 files 目录的路径两条路径都必须能在 FileProvider 中查到。2. 现象相册选择了一张图裁剪界面显示不正常或者裁剪完成后 onActivityResult 返回的 data 为 null。原因com.android.camera.action.CROP 裁剪 Intent 在不同 ROM 上实现差异非常大。部分 ROM 裁剪完不会通过 onActivityResult 返回数据而是直接把结果写到 EXTRA_OUTPUT 指定的 Uri 中部分 ROM 则在裁剪界面渲染时读不到 content:// 类型的输入数据。解决不要依赖 data 参数统一以裁剪前创建的输出 File 是否有内容为准。在 onActivityResult 回调里无条件检查 outputFile 是否存在且长度大于 0如果存在就直接走后续流程根本不需要关心 Intent 的返回内容。同时给裁剪 Intent 一个 try-catch 包裹 ActivityNotFoundException 作为降级路径某些精简 ROM 是完全不带系统裁剪组件的。3. 现象每次拍照后应用内存占用明显增长反复操作几次后界面卡顿甚至 OOM。原因拍照返回的原图可能是 4000x3000 像素的大图在压缩和裁剪时直接解码导致内存峰值飙升。另外 onActivityResult 里没有及时回收 Bitmap 对象造成内存泄漏累积。解决解码前使用 inJustDecodeBounds 采样确保位图在进入内存前已经被缩小。采样率计算可以比「2 倍阈值」更激进比如目标尺寸是 300x300当图片尺寸超过 2048x2048 时直接设置 sampleSize4这能在视觉上无损地大幅降低内存占用。每次使用完 Bitmap 后调用 recycle 方法特别是在有循环操作的项目中这一步能防止内存问题的累积。流量方面在弱网环境下Base64 字符串因为体积膨胀问题损失的网络效率也是一个值得注意的点——如果用户网络很差上传超时概率会明显上升。4. 现象JS 层回调有时候触发有时候完全不触发且毫无规律或者回调执行时伴随报错 Script feedback is missing。原因JS 回调的线程归属问题。如果 evalString 在非 GL 线程调用JS 引擎可能已经处于空闲状态调用被静默丢弃。或者回调时机过早JS 端场景脚本还没有注册监听。解决原生回调统一走 CocosHelper.runOnGameThread 投递到主线程JS 端的事件监听要在场景 onLoad 中提前注册避免原生处理速度极快导致事件先于监听触发而丢消息。调试阶段可以在原生代码中加一个历史回调队列把有回调但 JS 端未消费的数据缓存 5 秒这样能区分是线程问题还是监听时序问题。5. 现象上传成功后服务端图片正常但下次登录时头像显示为空白检查 URL 无误。原因下载请求丢失了身份认证信息。头像通常需要携带登录态才能访问而 cc.loader.load 使用的是独立请求通道不会自动附加游戏 HTTP 层的 token 头。解决如果服务端做了鉴权就不要用 cc.loader.load 直接加载而是先用 XMLHttpRequest 请求图片的 Base64 数据拿到后再构造纹理。或者让服务端对头像 URL 生成短期有效的签名 URL这样既能保留 CDN 加速能力又不需要在加载器层做鉴权改造。6. 二次开发从单图上传升级为头像裁剪套件这套体系跑通后二次开发的方向很明确把「选图 裁剪 上传 回显」的管线抽象成一个独立的组件供项目中所有需要上传图片的场景复用。比如游戏内的聊天表情上传、用户相册功能、实名认证的证件照上传。这些场景的技术骨架完全一致只是参数不同。改造的第一步是把固定写死的 300x300 裁剪尺寸改成可配置项。在原生裁剪方法中增加裁剪宽高参数的透传JS 层封装时通过参数对象传入。裁剪比例也需要支持比如身份证件照需要的 3:4 比例、聊天图片需要的自由比例、朋友圈封面需要的 16:9 比例。aspectX 和 aspectY 就是给系统裁剪界面用的outputX 和 outputY 则是实际输出分辨率。一个实用的建议是outputX 不要直接等于最终显示尺寸而是乘以 2 或 3让服务端有压缩或者高清展示的余地。第二步是相册入口从单选升级为多选。原生层在相册 Intent 上增加 EXTRA_ALLOW_MULTIPLE回调时遍历 ClipData 拿到多张 Uri逐张走压缩流程最后拼装成一个 Base64 数组放进 JSON 里一次性回调给 JS 层。JS 侧循环调用上传接口即可。注意多选场景下的进度提示要计算「已上传张数 / 总张数」在界面上实时更新而不是闷头传完全部再提示。这一步我一般会在原生层就把每张图片压缩好后再拼数组避免一次性把所有原图都解码进内存——如果是 9 张图同时解码OOM 的几率几乎无法避免。第三步是把上传接口从单一 Base64 字段升级为可插拔的存储适配器。有些项目有自己的 OSS 直传服务有些项目用云存储还有些项目希望图片直接转存到服务器磁盘。实现上可以抽象一个 UploadStrategy 接口策略实现分为「Base64 JSON 上传」「multipart 文件流上传」「OSS 签名直传」三种。JS 层只需要通过配置项切换策略上层调用代码完全不需要改动。写到这里我想起之前做过的一个社交项目就是因为一开始没做这层抽象后来从服务器磁盘存储切换到对象存储时几乎把上传链路重写了一遍。从那以后我所有涉及上传功能的模块都会先花半小时把存储后端抽象一层再开始写业务哪怕当前只用一个存储服务这个习惯能避免非常多的后期返工。希望这个项目的实现和踩坑记录能帮到你在 Creator 与 Android 原生融合的开发路上少走几步弯路。本文还有配套的精品资源点击获取