简介:资源围绕 Creator 场景下的 Android 相机/相册调用、图片裁剪及头像上传下载展开,适合需要实现用户头像选择与传输功能的移动端开发者。内容覆盖运行时权限申请、Intent 启动系统相机与相册、系统裁剪工具调用、Bitmap 本地持久化,以及基于 OkHttp 的文件上传与头像下载流程,并给出可直接参考的 AvatarManager 封装思路。压缩包为 zip 格式,共 321 个文件,约 5.12MB,主要包含 Java/JS 源码、Gradle 工程配置、Android 资源文件、APK 安装包及原始 proto 数据文件,文件类型较完整,便于对照工程结构进行二次开发。已有 2439 人学习使用,适合具有一定 Android 基础并希望快速落地图片处理模块的开发者参考。
1. 方案选型:为什么必须走"原生桥接",而不是纯JS实现
先交代一下背景。我在用Cocos Creator做一款工具型App的时候,遇到了一个绕不过去的需求:点击按钮唤起Android系统相机拍照、打开相册选图、拿到图片后裁剪,最后把处理完的图片上传到自己的服务器,并且在需要的时候能从服务器下载回手机并展示。这个链路听起来不复杂,但真正落地时你会发现,Cocos Creator本身并不直接提供"调起Android系统相机"的能力。引擎的web层API只覆盖了浏览器环境下的文件选择,放到Android原生环境里,行为表现非常不一致。
当时第一版方案我试过纯Web方式,用input picking图片,在部分Android机型上确实能弹相册,但相机拍照的返回结果不可控,裁剪更是完全没有统一方案。不同厂商的系统浏览器、不同版本的WebView对文件选择器的处理逻辑不一样,这对一个要发布成APK的项目来说,意味着后续漫无边际的兼容性维护。所以最终我把方案收敛到"原生插件 + JSB桥接"这条路径上。
所谓原生插件,就是你在Android Studio里写一个Module,把调用相机、相册、裁剪的逻辑全部放在Java/Kotlin层完成。Cocos Creator通过内置的JSB(JavaScript Binding)机制,在JS脚本里直接调用原生Java对象的public方法。换句话说,引擎负责UI、逻辑和网络层,Android原生层负责所有涉及系统能力的脏活累活。这种分离非常清晰,也让两端各自只做自己最擅长的事。
这里有一个关键点:Cocos Creator 2.x版本中,JSB这一块的接口调用方式和3.x有一些差异。我这次项目用的是2.4.x版本,如果你用的是3.x,好在整体思路一致,只是在jsb的引用方式、线程切换细节上略有不同。下文所有代码示例我会标注版本差异,方便你对照。
2. 核心细节解析:Camera/相册调用的整套机制
2.1 拍照与选图中必经的FileProvider适配
Android系统在7.0(API 24)之后,禁止在应用之间通过"file://"格式的URI分享文件,否则会直接抛出FileUriExposedException。这意味着你不能简单地通过Intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(tempFile))来指定拍照输出路径。标准做法是使用FileProvider,在AndroidManifest.xml里注册一个ContentProvider,并通过它生成"content://"格式的URI。
FileProvider的配置分为两步。第一步,在AndroidManifest.xml的application节点内添加provider声明:
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>第二步,在res/xml目录下新建file_paths.xml文件:
<?xml version="1.0" encoding="utf-8"?> <paths> <external-path name="external" path="." /> <files-path name="files" path="." /> <cache-path name="cache" path="." /> </paths>这里我踩过一个大坑。如果你只是在file_paths.xml里配置了external-path指向整个SD卡根目录,那么应用读取自己私有目录下的图片是没问题的,但如果你把图片输出到了公共目录(比如DCIM),在部分Android 11及以上机型上会触发分区存储的限制,导致相册里看不到刚拍的照片。所以我的做法是,拍照输出路径统一用getExternalFilesDir()下的子目录,既避开了分区存储的坑,又能保证FileProvider正常授权。
2.2 相机拍照与相册选图的Intent构建差异
调起相机拍照的Intent看起来很简单:
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);但要注意两个细节。第一,必须为Intent添加读写URI的临时授权标志,否则相机应用写入文件时会报权限错误。第二,拍照返回结果里如果onActivityResult返回了data且data.getData()不为空,那只是缩略图,不要拿它当原图。正确的做法是直接用你预先创建好的photoUri去读文件,因为原图压根就不存在于返回的data里。
相册选图在Android 13(API 33)之后有了新变化。系统推出了Photo Picker组件,不需要申请任何存储权限,用户选择的范围也更明确,Google强烈推荐使用它。但在Android 13以下的机型上,你仍然需要通过ACTION_GET_CONTENT或ACTION_PICK打开相册。兼容性处理上,我在代码里做了版本判断:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES); startActivityForResult(intent, REQUEST_CODE_ALBUM); } else { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); startActivityForResult(intent, REQUEST_CODE_ALBUM); }2.3 裁剪Intent的适配与系统裁剪的局限性
裁剪功能我第一版用了系统自带的裁剪Intent:com.android.camera.action.CROP。在开发测试机上一切正常,但上线后陆续收到用户反馈,部分国产ROM上裁剪功能调不起来,有的直接崩溃。原因是各大厂商在定制系统时,裁剪Activity的名字、包名都做了改动,ACTION_CROP这个隐式Intent在部分系统上根本匹配不到能处理的组件。
这也是为什么很多商用App最终都选择了自研裁剪控件。但自研的工程量不小,涉及触摸缩放、旋转、区域裁剪、图片压缩算法。对个人开发者和中小型项目来说,性价比最高的方案是引入成熟的开源库,比如uCrop。我的最终方案就是在原生层集成了uCrop,交互体验统一,也避开了厂商兼容性问题。uCrop用法很简单:
UCrop.of(sourceUri, destinationUri) .withAspectRatio(1, 1) .withMaxResultSize(1024, 1024) .start(context);3. 实操过程:从Cocos Creator到Android原生再到上传下载的完整链路
3.1 Cocos Creator端的Jsb桥接封装
在Cocos Creator 2.4.x中,JS调用原生Java方法的标准方式是:
import { Native } from 'cc'; // 调用无返回值的方法 Native.callNative('com.example.utils.ImageBridge', 'takePhoto', param1, param2); // 调用有返回值的方法(同步) const result = Native.callNative('com.example.utils.ImageBridge', 'getDeviceInfo');但相机相册这类异步交互,不能简单地靠callNative同步拿结果。原生层拿到图片后需要回调到JS层。我是这样设计的:在Java层定义好回调方法名,图片处理完成后调用Cocos引擎的evalString接口,把结果以JSON字符串的形式传回JS。
Java端回调JS的代码:
// 在原生层拿到裁剪后的图片路径后 String jsonResult = "{\"status\":\"success\",\"imagePath\":\"" + croppedPath + "\"}"; String jsCallback = "window.jsbBridge.onImageCropped(" + jsonResult + ");"; CocosHelper.runOnGameThread(new Runnable() { @Override public void run() { CocosActivity activity = (CocosActivity) CocosHelper.getActivity(); activity.runOnUiThread(() -> { // 通过JNI调用JS层的全局函数 CocosJavascriptJavaBridge.evalString(jsCallback); }); } });这里有一个线程问题值得专门提醒。CocosHelper.runOnGameThread是切到引擎的GL线程,但JNI调用JS的evalString方法有其自身的线程要求。我经过多次实测发现,更稳妥的方式是直接通过runOnUiThread切回UI线程,再调用CocosJavascriptJavaBridge.evalString。如果你直接在原生回调所在的Binder线程里调用evalString,轻则回调不执行,重则崩溃。此外,CocosCreator 3.x中CocosJavascriptJavaBridge的包路径发生了变化(变成了com.cocos.lib.CocosJavascriptJavaBridge),项目升版本时需要同步修改。
3.2 JS侧完整封装
JS侧我封装了一个独立的ImageBridge模块,对外暴露三个核心方法:takePhoto()、chooseFromAlbum()、uploadImage()。每个方法都返回Promise,这样在业务脚本里可以用await优雅地串联整个流程。核心实现如下:
const ImageBridge = { _nativeCallback: null, init() { // 注册原生层回调的全局函数 window.jsbBridge = { onImageCropped: (resultJson) => { const result = JSON.parse(resultJson); if (this._nativeCallback) { this._nativeCallback(result); this._nativeCallback = null; } } }; }, takePhoto() { return new Promise((resolve, reject) => { this._nativeCallback = resolve; Native.callNative('com.example.utils.ImageBridge', 'takePhoto'); }); }, chooseFromAlbum() { return new Promise((resolve, reject) => { this._nativeCallback = resolve; Native.callNative('com.example.utils.ImageBridge', 'chooseFromAlbum'); }); } };业务侧的使用就非常丝滑了:
async function handleUserAvatar() { const photoResult = await ImageBridge.takePhoto(); if (photoResult.status === 'success') { const uploadResult = await NetworkManager.uploadFile(photoResult.imagePath); // 上传成功后的处理 } }3.3 原生层拍照、裁剪核心实现
Java端我写了"一次封装、两处复用"的结构。一个Activity用Fragment承载,把拍照和选图的结果统一回调到同一个处理方法里。拿到图片后,先统一转成同一个存储路径下的临时文件,再进入裁剪流程:
public class ImageBridge { private static final int REQUEST_TAKE_PHOTO = 1001; private static final int REQUEST_PICK_IMAGE = 1002; private static final int REQUEST_CROP = 1003; private Activity activity; private String currentPhotoPath; public void takePhoto() { // 生成输出路径 String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); String fileName = "IMG_" + timeStamp + ".jpg"; File storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES); File photoFile = new File(storageDir, fileName); currentPhotoPath = photoFile.getAbsolutePath(); Uri photoUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", photoFile); Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); activity.startActivityForResult(intent, REQUEST_TAKE_PHOTO); } public void chooseFromAlbum() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Intent intent = new Intent(MediaStore.ACTION_PICK_IMAGES); activity.startActivityForResult(intent, REQUEST_PICK_IMAGE); } else { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); activity.startActivityForResult(intent, REQUEST_PICK_IMAGE); } } public void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode != Activity.RESULT_OK) return; Uri sourceUri = null; switch (requestCode) { case REQUEST_TAKE_PHOTO: sourceUri = Uri.fromFile(new File(currentPhotoPath)); break; case REQUEST_PICK_IMAGE: sourceUri = data.getData(); break; } if (sourceUri != null) { startCrop(sourceUri); } } private void startCrop(Uri sourceUri) { String destPath = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + "/crop_" + System.currentTimeMillis() + ".jpg"; Uri destUri = Uri.fromFile(new File(destPath)); UCrop.of(sourceUri, destUri) .withAspectRatio(1, 1) .withMaxResultSize(1024, 1024) .start(activity); } }3.4 上传下载的整体设计与分片思路
图片上传与下载这里要分两种情况。一种是普通的头像、用户反馈截图等小图,大小几百KB到几MB,直接用HTTP POST的multipart/form-data上传即可。另一种是项目要用到的现场照片,单张可能超过10MB,如果网络环境差,失败概率显著升高,这时就需要考虑分片上传。
我在项目里封装了一个支持分片上传的接口。核心思路是把文件切成固定大小的分片(比如每片1MB),逐片上传,服务器端记录每片序号,最后合并。这个方案需要服务端的配合,如果服务端不支持合并分片,就只能压缩图片后再上传。裁剪后的图片通常已经经过压缩处理(uCrop的withMaxResultSize会限制输出尺寸),大多数场景下走普通multipart上传就够了。
上传的核心代码:
uploadFile(filePath) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', { uri: 'file://' + filePath, name: Date.now() + '.jpg', type: 'image/jpeg' }); // 使用XMLHttpRequest或引擎自带的network模块 const xhr = new XMLHttpRequest(); xhr.open('POST', 'https://your-server.com/api/upload'); xhr.onload = () => { if (xhr.status === 200) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error('upload failed: ' + xhr.status)); } }; xhr.onerror = (e) => reject(e); xhr.send(formData); }); }下载侧我采用的是"先下载到本地,再显示"的策略。这里有个Cocos Creator特有的问题:引擎的资源加载接口load()加载图片是按"assets/resources"下的资源路径来索引的,你无法直接给它传一个绝对路径来加载本地相册里的文件。正确做法是用cc.assetManager.loadRemote()加载"file://"协议开头的本地文件:
downloadAndShow(imageUrl) { // 方案一:直接远程加载 cc.assetManager.loadRemote(imageUrl, (err, texture) => { if (!err) { const spriteFrame = new cc.SpriteFrame(texture); this.avatarSprite.spriteFrame = spriteFrame; } }); // 方案二:先下载到本地再加载(适合需要离线使用的场景) // 原生层先下载到指定目录,然后 JS 层传 file:// 路径给 loadRemote }但loadRemote每次加载都会发起网络请求,对已经下载过的文件不够友好。所以我的方案是:原生层先通过DownloadManager或OkHttp把图片下载到应用私有目录,Cocos侧拿到本地路径后,再用loadRemote加载file://路径,这样后续读取走的是本地文件,速度有明显提升。
4. 常见问题与排查技巧实录
我把项目过程中踩过的坑整理成了一张表,每个问题都是真实发生过的,排查思路和解决方式如下:
| 现象 | 根因分析 | 解决方案 |
|---|---|---|
| 拍照后返回,图片文件为空或大小只有0KB | 没有为Intent添加FLAG_GRANT_WRITE_URI_PERMISSION,或相机无法写入FileProvider映射的目录 | 检查FileProvider配置路径,确保getExternalFilesDir目录被external-path覆盖;添加读写URI权限标志 |
| Android 7.0以上崩溃,报FileUriExposedException | 直接使用了file:// URI跨应用传递 | 全局搜索"Uri.fromFile",全部替换为FileProvider.getUriForFile() |
| 部分国产手机上无法调起裁剪 | 系统裁剪Activity被厂商移除或改名 | 弃用ACTION_CROP,改用uCrop等自研裁剪方案 |
| JS回调不执行,或执行延迟严重 | 在非UI线程直接调用了CocosJavascriptJavaBridge.evalString | 先runOnUiThread切回UI线程,再执行evalString;将回调逻辑拆成"原生结果缓存 + JS主动拉取"双保险 |
| Android 13上相册选图返回空 | 没有适配Photo Picker,仍用旧权限模型 | 增加API 33分支,使用MediaStore.ACTION_PICK_IMAGES |
| 下载图片后loadRemote加载失败,报404 | file://路径格式不正确,或文件确实不存在 | 先通过原生层File.exists()确认文件存在;路径处理时统一使用encodeURI()对特殊字符编码 |
| 上传大图时OOM崩溃 | 裁剪后的图片尺寸过大,直接一次性加载到内存 | 先在原生层对图片做采样压缩(inSampleSize),限制最大边不超过2048像素,再用multipart上传 |
关于Android 13的相册权限还有一个容易漏的点。就算你强行申请了READ_MEDIA_IMAGES权限,在部分国产系统上仍然不会弹授权窗口,因为系统版本代码是13但上层定制没跟上。所以我的建议是直接优先走Photo Picker,它不需要任何权限,而且在任何Android 13机型上表现都一致。
另外一个调试技巧:在真机上调试原生代码时,务必打开Android Studio的Logcat,过滤"ImageBridge"标签查看原生层日志。JS层的报错要看Cocos Creator的Console面板,但很多原生层的异常信息不会传递到引擎层,不看Logcat你根本不知道崩溃原因。另外推荐在原生onActivityResult里添加一个兜底日志,把所有requestCode和resultCode打出来,这样排查"点击拍照后没反应"这类问题时能快速定位是Intent没调起来,还是回调没走到。
还有一点是关于权限申请的时机。如果你的App启动时一口气申请了存储权限、相机权限,用户看到一堆弹窗很容易产生抵触心理,而且在部分国产ROM上,用户拒绝一次之后再申请就会被系统政策限制,非常被动。我的做法是把权限申请延后到用户真正点击"拍照"或"选择相册"按钮时,动态按需申请,这样成功率最高。
5. 一个容易忽略的隐患:图片方向与EXIF信息
这个问题我在项目后期才暴露出来,但一旦出现就是大批量反馈。很多手机拍照时传感器方向是横向的,拍摄出来的照片在相册里显示正常,是因为相册客户端自动读取了照片的EXIF方向信息并做了旋转。但当我们通过Intent.ACTION_PICK或相机拍照获取到原始图片文件后,如果直接加载到ImageView或上传到服务器,图片往往是横着的,或者被旋转了90度/270度。
解决方案是在裁剪之前先读取EXIF的orientation字段,主动把图片旋转到正确的方向。uCrop内部其实已经做了方向校正,所以如果你的链路是"拍照/选图 → uCrop → 拿到结果",这个坑基本不会踩到。但如果你在某些场景下省略了裁剪步骤(比如用户选择了"原图上传"),就必须自己在原生层做方向校正:
private int getImageOrientation(String path) { try { ExifInterface exif = new ExifInterface(path); int orientation = exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); switch (orientation) { case ExifInterface.ORIENTATION_ROTATE_90: return 90; case ExifInterface.ORIENTATION_ROTATE_180: return 180; case ExifInterface.ORIENTATION_ROTATE_270: return 270; default: return 0; } } catch (IOException e) { return 0; } }拿到角度后用Matrix做旋转再保存,这样无论用户怎么转手机,最终上传到服务器的图片方向始终正确。
6. 写在最后:根据这次实践的个人体会
整个链路从需求提出到最后稳定上线,前后花了大概两周时间。回过头来看,最耗时间的其实不是代码本身,而是兼容性验证。Android设备的碎片化程度比你想象的更严重,同一段代码在小米上表现正常,到了OPPO上可能就没有弹裁剪页,到了三星上又可能图片方向错误。如果你没有条件做大量真机测试,至少保证三台不同品牌的Android手机覆盖测试:一台最新系统版本、一台Android 10~12、一台国产定制ROM。这样能覆盖绝大多数用户。
另外想提醒一点:Cocos Creator的JSB方案只是一个工具,不要把所有逻辑都塞到原生层,也不要全部放在JS层。我建议把规则定为"涉及系统能力的操作一律走原生,涉及业务逻辑的统一走JS",这个分界会让后续的维护轻松很多。最后再分享一个小细节:上传时给文件命名加上时间戳和随机数,避免不同用户上传同名前互相覆盖。这个问题看起来不起眼,但在多人测试时经常让人排查半天。
本文还有配套的精品资源,点击获取