简介:面向Android与Cocos Creator开发者的图片交互实战资源,聚焦调用系统相机与相册、裁剪图片并完成上传下载的完整链路,适合需要实现头像上传、图片采集等功能的初中级开发者参考。内容覆盖运行时权限申请、Intent启动相机与相册、onActivityResult结果接收、系统裁剪工具参数配置、Bitmap压缩存盘,以及基于OkHttp的Multipart上传与URL下载保存,并以AvatarManager封装统一接口。压缩包共321个文件、约5.12MB,以json配置与rawproto数据为主,辅以js脚本、jar与aar依赖库、xml布局、png图片及少量java源码和apk演示包,另含gradle构建文件与说明文档,目录结构便于按模块查阅。已有2446人学习下载,可据此梳理权限、裁剪、文件与网络请求各环节的衔接方式,快速搭建稳定的图片处理模块。
1. 从一次头像上传翻车说起:这个项目到底解决什么问题
去年帮一个做社交类小游戏的朋友救火,用户点「换头像」之后,安卓机上要么直接闪退,要么选完图回来头像框一片空白,好不容易显示出来了,上传到服务端又是一张被压得糊成马赛克的图。排查下来发现,问题根本不在业务逻辑,而是 Cocos Creator 调用 Android 原生相机、相册这条链路没打通:JS 层拿不到真实的图片路径,原生层返回的 URI 在 Creator 里读不了,裁剪参数传过去又被系统相机忽略。这个项目就是把这套「相机拍照 / 相册选图 → 原生裁剪 → 回传 Creator → 上传服务端 → 再下载回显」的完整闭环做成了一个可复用的工程模板,适合正在做 Creator 安卓端头像、证件照、商品图上传的开发者直接拿去改。它不教你 JS 语法,而是把 JS 与 Java 之间那层最容易翻车的桥接代码摊开给你看。
2. 技术栈与工程结构:JS 桥接层和 Android 原生层怎么分工
2.1 整体技术选型与职责边界
这个项目的技术栈很克制,没有引入任何第三方图片库,全部靠 Creator 自带的native反射机制加 Android 系统原生 API 完成。核心思路是:Creator 的 TypeScript 层只负责 UI 交互和网络请求,所有跟相机、相册、裁剪相关的脏活全部丢给 Android 原生层,两边通过jsb.reflection.callStaticMethod做同步调用,原生处理完再通过evalString回调 JS。
这样分工的好处很直接。相机权限申请、FileProvider配置、Intent跳转、Bitmap裁剪这些操作在 JS 层根本做不了,硬要用插件绕反而增加不确定性;而网络上传、进度回调、UI 状态管理放在 TS 层写起来又比 Java 舒服得多。所以边界划在「原生只管拿到一张裁剪好的图片并落盘,JS 只管把这个文件读出来传走」这条线上,两边耦合度最低。
常见做法是原生层把裁剪结果统一存到应用私有目录下的cache/avatar/里,返回一个绝对路径字符串给 JS,JS 用native.fileUtils读成ArrayBuffer再走XMLHttpRequest上传。这样原生层不需要关心服务端地址,JS 层也不需要关心图片是怎么来的,后续换上传接口或者换裁剪 UI 都只动一边。
2.2 目录结构拆解
工程目录大致是这样组织的,原生部分放在build-templates下,保证每次构建都能自动合并进去,而不是手动往build目录里拷(手动拷是血泪教训,下次构建就没了):
project/ ├── assets/ │ ├── scripts/ │ │ ├── NativeBridge.ts # JS 调用原生的统一封装 │ │ ├── AvatarController.ts # 头像业务:选图、上传、下载、回显 │ │ └── UploadService.ts # 上传下载的网络层 │ └── scenes/ │ └── Main.scene ├── build-templates/ │ └── android/ │ └── app/ │ ├── src/org/cocos2dx/javascript/ │ │ ├── AppActivity.java # 主 Activity,注册回调 │ │ ├── CameraHelper.java # 相机/相册/裁剪封装 │ │ └── ImageCallback.java # 回调 JS 的桥 │ ├── res/xml/file_paths.xml # FileProvider 路径配置 │ └── AndroidManifest.xml # 权限与 Provider 声明 └── settings/build-templates/android这个目录是 Creator 官方支持的构建模板覆盖机制,放在这里的文件会在构建时覆盖到build/android对应位置。把原生代码放这里,团队里任何人拉代码构建出来的包都带这套逻辑,不用再口头交代「记得把 Java 文件拷进去」。
2.3 原生层的关键配置
在动手写代码前,AndroidManifest.xml和file_paths.xml必须先配对,否则后面FileProvider.getUriForFile一定抛IllegalArgumentException。清单里要声明相机权限、读取媒体权限,以及一个provider节点:
<!-- AndroidManifest.xml 片段 --> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <application> <!-- authority 建议用包名 + .fileprovider,避免和其他库冲突 --> <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> </application>file_paths.xml里把拍照临时目录和裁剪输出目录都暴露出来:
<paths> <!-- 拍照原图临时存放 --> <cache-path name="camera_cache" path="camera/" /> <!-- 裁剪后的头像输出目录 --> <cache-path name="avatar_cache" path="avatar/" /> </paths>这里的path是相对于context.getCacheDir()的子目录,所以 Java 里拼路径时要用new File(getCacheDir(), "avatar"),两边必须严格对应。我见过有人写external-cache-path结果在没插 SD 卡的机器上直接崩,用cache-path最稳。
3. 核心链路拆解:从点击按钮到图片落盘的完整调用
3.1 JS 侧的统一桥接封装
所有对原生的调用都收口到一个NativeBridge.ts里,避免业务代码里到处散落jsb.reflection字符串。这样原生方法名改了只改一处,也方便在非安卓平台做空实现降级:
// NativeBridge.ts export class NativeBridge { // 判断当前是否安卓原生环境,编辑器/浏览器预览时直接返回 false static isAndroid(): boolean { return sys.platform === sys.Platform.ANDROID && !!jsb.reflection; } // 打开相机拍照并裁剪,callback 收到裁剪后的绝对路径 static takePhoto(callback: (path: string) => void): void { if (!this.isAndroid()) { console.warn('takePhoto 仅在安卓原生生效'); return; } // 第三个参数是回调函数名,原生层通过 evalString 调回全局函数 (window as any).__onNativeImageResult = (path: string) => { callback(path); }; jsb.reflection.callStaticMethod( 'org/cocos2dx/javascript/CameraHelper', // 类全限定名,用 / 分隔 'takePhoto', // 静态方法名 '(Ljava/lang/String;)V', // 方法签名:接收一个 String,返回 void '__onNativeImageResult' // 回调函数名 ); } // 打开相册选图并裁剪 static pickFromGallery(callback: (path: string) => void): void { if (!this.isAndroid()) return; (window as any).__onNativeImageResult = (path: string) => callback(path); jsb.reflection.callStaticMethod( 'org/cocos2dx/javascript/CameraHelper', 'pickFromGallery', '(Ljava/lang/String;)V', '__onNativeImageResult' ); } }方法签名(Ljava/lang/String;)V是 JNI 类型描述符,L...;表示对象类型,V表示 void。这里最容易翻车的是签名写错,比如把String写成Ljava/lang/String(少了分号),运行时会直接抛no matching method,而且报错信息很含糊,只告诉你方法找不到,不告诉你签名错了。参数类型对照可以记一下:int是I,boolean是Z,float是F,对象一律L包名/类名;。
回调函数挂在window上,是因为evalString执行的是全局作用域里的 JS 表达式,原生层拼出来的字符串类似window.__onNativeImageResult('路径'),所以这个函数必须是全局可见的。用(window as any)挂载是 TypeScript 下最省事的写法。
3.2 原生侧相机与相册的 Intent 跳转
CameraHelper.java里两个入口方法结构对称,都是先申请权限再发Intent。拍照要先用FileProvider生成一个临时文件的 URI 传给相机,否则 Android 7.0 以上会直接抛FileUriExposedException:
// CameraHelper.java public static void takePhoto(String callbackName) { Activity activity = Cocos2dxHelper.getActivity(); sCallbackName = callbackName; // 临时原图文件,放在 cache/camera 下 File dir = new File(activity.getCacheDir(), "camera"); if (!dir.exists()) dir.mkdirs(); sRawFile = new File(dir, "raw_" + System.currentTimeMillis() + ".jpg"); // 必须通过 FileProvider 拿 content:// URI,不能直接传 file:// Uri uri = FileProvider.getUriForFile( activity, activity.getPackageName() + ".fileprovider", sRawFile); Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); intent.putExtra(MediaStore.EXTRA_OUTPUT, uri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); activity.startActivityForResult(intent, REQ_TAKE_PHOTO); }相册选图则用ACTION_PICK或ACTION_GET_CONTENT,前者直接进图库,后者会弹应用选择器,做头像场景用ACTION_PICK体验更顺:
public static void pickFromGallery(String callbackName) { Activity activity = Cocos2dxHelper.getActivity(); sCallbackName = callbackName; Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); intent.setType("image/*"); activity.startActivityForResult(intent, REQ_PICK_GALLERY); }REQ_TAKE_PHOTO和REQ_PICK_GALLERY是两个自定义的requestCode常量,在AppActivity.onActivityResult里靠它们区分结果来源。这里有个细节:Cocos2dxHelper.getActivity()拿到的是 Creator 的主 Activity,startActivityForResult必须由 Activity 发起,不能在静态方法里用Application context,否则回调收不到。
3.3 裁剪环节:系统裁剪 Intent 的参数怎么调
拿到原图 URI 后统一走系统裁剪,com.android.camera.action.CROP这个 action 虽然不是官方标准,但主流 ROM 都支持,比自己写裁剪界面省事得多:
private static void startCrop(Activity activity, Uri sourceUri) { File dir = new File(activity.getCacheDir(), "avatar"); if (!dir.exists()) dir.mkdirs(); sCropFile = new File(dir, "avatar_" + System.currentTimeMillis() + ".jpg"); Uri destUri = FileProvider.getUriForFile( activity, activity.getPackageName() + ".fileprovider", sCropFile); Intent intent = new Intent("com.android.camera.action.CROP"); intent.setDataAndType(sourceUri, "image/*"); // 关键:给裁剪应用授予读写权限,否则部分机型读不到源图 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); intent.putExtra("crop", "true"); intent.putExtra("aspectX", 1); // 宽高比 1:1,头像场景固定 intent.putExtra("aspectY", 1); intent.putExtra("outputX", 400); // 输出像素,别设太大,否则 OOM intent.putExtra("outputY", 400); intent.putExtra("output", destUri); // 输出到我们指定的 URI intent.putExtra("outputFormat", "JPEG"); intent.putExtra("return-data", false); // 必须 false,true 会走 Binder 传大图崩掉 activity.startActivityForResult(intent, REQ_CROP); }return-data设成true是新手最常踩的坑:它会把裁剪结果塞进Intent的extras里通过 Binder 传回,而 Binder 单次传输上限约 1MB,400x400 的图勉强能过,稍微大一点就TransactionTooLargeException闪退。设成false配合output指定文件 URI,让裁剪应用直接写文件,稳得多。
aspectX/aspectY控制裁剪框比例,outputX/outputY控制最终输出尺寸。做头像固定 1:1、400px 足够清晰又不至于上传太慢;如果做商品图需要自由比例,把aspectX/aspectY去掉即可,但输出尺寸还是要限制。
3.4 结果回传:onActivityResult 到 evalString
AppActivity里重写onActivityResult,按requestCode分流。拍照和相册选图拿到 URI 后都转去裁剪,裁剪完成才真正回调 JS:
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode != RESULT_OK) return; // 用户取消,直接返回,不回调 if (requestCode == REQ_TAKE_PHOTO) { // 拍照结果在 sRawFile 里,data 为 null Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", CameraHelper.getRawFile()); CameraHelper.startCrop(this, uri); } else if (requestCode == REQ_PICK_GALLERY) { if (data != null && data.getData() != null) { CameraHelper.startCrop(this, data.getData()); } } else if (requestCode == REQ_CROP) { // 裁剪完成,文件已写到 sCropFile,把绝对路径回传 JS String path = CameraHelper.getCropFile().getAbsolutePath(); String js = "window." + CameraHelper.getCallbackName() + "('" + path + "')"; Cocos2dxHelper.runOnGLThread(() -> Cocos2dxJavascriptJavaBridge.evalString(js)); } }evalString必须在 GL 线程执行,所以套了一层runOnGLThread。直接在onActivityResult(主线程)里调evalString会偶发崩溃或者 JS 侧收不到回调,这个坑很隐蔽,因为不是每次都复现。路径里的反斜杠在 Windows 上不存在,安卓路径都是/,所以直接拼进单引号里是安全的,但如果路径可能含单引号(几乎不会),要做转义。
3.5 上传与下载:JS 侧读文件走 XHR
原生回调拿到绝对路径后,JS 用native.fileUtils读成ArrayBuffer,再包成Blob走XMLHttpRequest上传。Creator 的assetManager加载不了应用私有目录下的绝对路径文件,必须走fileUtils:
// UploadService.ts export function uploadAvatar(filePath: string, uploadUrl: string): Promise<string> { return new Promise((resolve, reject) => { // 读取原生文件,返回 ArrayBuffer const data = native.fileUtils.getDataFromFile(filePath); if (!data) { reject(new Error('读取文件失败')); return; } const blob = new Blob([data], { type: 'image/jpeg' }); const form = new FormData(); form.append('avatar', blob, 'avatar.jpg'); // 字段名按服务端约定改 const xhr = new XMLHttpRequest(); xhr.open('POST', uploadUrl); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { // 服务端返回图片 URL,用于后续下载回显 resolve(JSON.parse(xhr.responseText).url); } else { reject(new Error('上传失败: ' + xhr.status)); } }; xhr.onerror = () => reject(new Error('网络错误')); xhr.send(form); }); }下载回显用assetManager.loadRemote最省事,它自带缓存,同一个 URL 第二次不会重复请求:
export function loadAvatarSprite(url: string, sprite: Sprite): void { assetManager.loadRemote<ImageAsset>(url, { ext: '.jpg' }, (err, imageAsset) => { if (err) { console.error('头像下载失败', err); return; } const texture = new Texture2D(); texture.image = imageAsset; sprite.spriteFrame = new SpriteFrame(); sprite.spriteFrame.texture = texture; }); }ext参数不能省,loadRemote靠它判断解码格式,不传的话在某些 CDN 返回的 URL 没有后缀时会解析失败。上传成功后服务端返回的 URL 建议带上时间戳或版本号,否则客户端缓存会导致换了头像还显示旧的。
4. 避坑与排查:安卓相机相册这条链路上的五个高频翻车点
4.1 选完图回来头像空白,日志无报错
现象:相册选图后回调正常触发,路径也拿到了,但getDataFromFile返回 null,头像框不更新。
原因:路径是content://开头的 URI 而不是文件绝对路径,fileUtils只认文件系统路径。有些 ROM 的裁剪应用在output参数处理上不规范,回传的data.getData()仍是 content URI。
解决:在onActivityResult里统一用我们自己指定的sCropFile.getAbsolutePath()回传,不要用data.getData()。裁剪输出 URI 是我们通过FileProvider指定的,文件一定落在cache/avatar/下,路径可控。
4.2 拍照直接闪退,报 FileUriExposedException
现象:点拍照按钮,应用崩溃,logcat 里是android.os.FileUriExposedException。
原因:Android 7.0 起禁止把file://URI 直接传给其他应用,必须用FileProvider转成content://。
解决:检查三处是否齐全——AndroidManifest里provider节点、res/xml/file_paths.xml里对应目录、代码里用FileProvider.getUriForFile而不是Uri.fromFile。三处缺一处都会崩,且报错位置不同,按顺序排查。
4.3 裁剪后图片方向转了 90 度
现象:竖着拍的照片,裁剪出来变成横的,或者头像歪了。
原因:部分机型相机写入的 JPEG 带 EXIF 旋转标记,系统裁剪应用读取时没处理这个标记,输出就歪了。
解决:在裁剪前用ExifInterface读TAG_ORIENTATION,如果非ORIENTATION_NORMAL,先用Matrix旋转Bitmap再送去裁剪。这一步多花几十毫秒,但能避免用户看到歪头像。如果不想在原生做,也可以在 JS 上传前用 canvas 重绘,但原生处理更省内存。
4.4 回调 JS 时偶发不执行
现象:十次里有一两次裁剪完 JS 侧没反应,重进页面又好了。
原因:evalString没在 GL 线程调用,或者回调函数名在 JS 侧被覆盖了(比如连续点两次按钮,第二次把window.__onNativeImageResult覆盖成新的闭包,第一次的回调丢失)。
解决:evalString一律包runOnGLThread;JS 侧在发起调用前先清空旧回调,或者用递增的requestId做匹配,回调里校验 id 再执行。简单场景下在按钮上做防连点(button.interactable = false)也能规避大半。
4.5 上传大图时内存暴涨甚至 OOM
现象:低端机上选完图上传,内存曲线飙升,偶尔闪退。
原因:getDataFromFile把整张图读进内存,如果裁剪输出设成 2000px,一张图解码后可能几十 MB,加上Blob和FormData的拷贝,内存翻倍。
解决:裁剪输出尺寸控制在 400~800px,头像场景 400 足够;上传前在原生侧做一次质量压缩(Bitmap.compress质量 80);如果确实要传大图,改用分片上传,别一次性读进内存。
5. 二次开发改造:把硬编码的裁剪参数和上传逻辑抽成配置
拿到这个工程后,最该动的不是 UI,而是把散落在各处的硬编码收口。我一般会做三件事,按优先级排。
第一件是把裁剪参数和上传地址抽成配置对象。原生侧的aspectX/outputX目前写死在startCrop里,JS 侧的uploadUrl写死在调用处,改一次要动两个语言的文件。做法是 JS 调用原生时把配置序列化成 JSON 字符串传过去,原生用JSONObject解析:
// 改造后:配置从一处传入 const cropConfig = { aspectX: 1, aspectY: 1, outputX: 400, outputY: 400, quality: 80 }; NativeBridge.takePhoto(JSON.stringify(cropConfig), (path) => { /* ... */ });// 原生侧解析配置,缺省值兜底 JSONObject cfg = new JSONObject(configJson); int aspectX = cfg.optInt("aspectX", 1); int outputX = cfg.optInt("outputX", 400); int quality = cfg.optInt("quality", 80);这样换业务场景(比如做证件照要 3:4)只改 JS 一处,原生代码不用碰。optInt带默认值比getInt安全,字段漏传不会抛异常。
第二件是给上传加失败重试和本地缓存。现在上传失败只 reject,用户得重新选图。改造思路是上传前把裁剪文件复制一份到writablePath/avatar_pending/,上传成功后删除,失败则保留,下次进页面检测到 pending 文件自动重传。验证方法很简单:开飞行模式上传,杀掉进程重进,看是否自动续传。这个改动对弱网场景体验提升明显,代码量也就几十行。
第三件是把原生回调改成 Promise 风格并支持超时。现在的回调是「发出去就不管了」,用户点了拍照又按返回键,JS 侧永远等不到回调,按钮一直灰着。加一个 30 秒超时 reject,配合finally恢复按钮状态:
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> { return Promise.race([ p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error('原生回调超时')), ms)) ]); }验证时重点看两个场景:拍照中途按返回、裁剪界面按返回,两种都应该在超时后恢复 UI 而不是卡死。
改造完记得在真机上过一遍完整链路,模拟器上的相机行为跟真机差别很大,尤其是ACTION_IMAGE_CAPTURE在模拟器上经常没有可用相机应用。从那以后我每次改完原生桥接代码,都强制在至少两台不同品牌的安卓真机上走一遍「拍照→裁剪→上传→下载回显」全流程,再顺手开一次飞行模式测失败分支,不然那些只在特定 ROM 上出现的坑,等上线了才暴露就晚了。希望帮到你。
本文还有配套的精品资源,点击获取