Cocos Creator调用Android相机相册与裁剪上传的JSB桥接实践

📅 发布时间:2026/9/8 6:14:59
Cocos Creator调用Android相机相册与裁剪上传的JSB桥接实践
简介资源围绕 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通过内置的JSBJavaScript 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.0API 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: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文件?xml version1.0 encodingutf-8? paths external-path nameexternal path. / files-path namefiles path. / cache-path namecache 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 13API 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的适配与系统裁剪的局限性裁剪功能我第一版用了系统自带的裁剪Intentcom.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加载失败报404file://路径格式不正确或文件确实不存在先通过原生层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这个分界会让后续的维护轻松很多。最后再分享一个小细节上传时给文件命名加上时间戳和随机数避免不同用户上传同名前互相覆盖。这个问题看起来不起眼但在多人测试时经常让人排查半天。本文还有配套的精品资源点击获取