简介:面向Cocos Creator开发者的Android相机相册调用与头像裁剪上传下载完整工程包,覆盖从权限申请、拉起相机/相册、系统裁剪到上传下载的完整流程,适合需要快速实现用户头像选择、裁剪及云端同步功能的中高级Android开发者。资源共321个文件、约5.12MB,主要包含Java/JS源码、Android配置XML、JSON与rawproto数据配置、Gradle构建脚本及调试日志相关工具,并附带可直接安装的Demo-release.apk,方便对照运行与二次开发。已有2439人学习下载,属于轻量但覆盖完整的实战示例。通过该资源可掌握动态权限申请、Intent启动相机与相册、系统裁剪工具调用、Bitmap压缩保存为本地文件,以及基于OkHttp上传图片和下载头像的完整链路。代码将AvatarManager封装为统一管理入口,结构清晰,便于集成到真实项目中,是理解Android端图片处理与网络传输细节的高性价比参考。 做游戏或者工具类App,经常会遇到这样一个需求:用户要换头像,点一下按钮,能呼起安卓系统的相机拍一张,或者从相册选一张,然后裁剪成正方形,再上传到服务器,下次登录还能下载回来显示。在Cocos Creator项目里,这个需求说难不难,说简单也不简单,核心点在于Creator本身是跨平台引擎,JS层的API并不会直接暴露安卓的相机相册能力,你需要自己补一层原生桥接。
我最早接这个需求的时候,第一反应是去搜插件,搜了一圈发现要么版本太老,要么代码写得很随意,拿到工程里根本跑不起来。后来干脆自己从头写了一套,从Android原生端的相机相册调用、系统裁剪,到Creator侧的JS调用封装,再到图片上传和下载,整个链路都趟了一遍,踩了不少坑。这篇文章就把完整的实现思路、核心代码和排坑过程整理出来,给正好卡在这个功能上的朋友一个参考。
适合谁看:正在用Cocos Creator 2.x或3.x做安卓App,需要在原生端完成图片选择、裁剪和上传下载,但又不想为了一个小功能去写完整原生工程的小伙伴。看完你至少能知道从哪下手,以及那些网上教程基本不会告诉你的坑都在哪里。
1. 方案选型与整体设计思路
动手之前先别急着写代码,先想清楚走哪条路。这个需求的本质是:JS层想要拿到一张裁剪好的本地图片,然后上传到服务器。路径无非就两条,一是纯网页能力,二是原生扩展。
1.1 为什么纯网页方案不靠谱
在Creator的Web端跑,<input type="file">就够了,浏览器自动帮你搞定相机相册选择。但打包成APK之后,Web能力是被裁剪过的,JS层没有可以安全弹出系统选择器的接口,更别提裁剪了。你当然可以用Canvas弯道超车,让用户选完图之后自己在游戏里拖拽裁剪框,但那种交互体验和系统裁剪比差太远,而且游戏画面的输入事件本身就和原生控件的尺寸、坐标系有冲突,调试成本非常高。
所以我最终选择的是原生扩展方案:在Android原生端把相机、相册、裁剪这三个动作全做了,然后把裁剪后的图片路径通过桥接层回传给Creator的JS层,由JS层负责上传和下载。这样做的好处是:原生端的代码逻辑稳定、权限处理成熟,裁剪界面对用户来说也是最熟悉的系统界面,不用自己画UI。
1.2 裁剪方案怎么选
裁剪是这里最容易出坑的一环。Android系统自带的裁剪Intent(com.android.camera.action.CROP)有一个很大的问题:不同厂商对它的实现差异极大。有的手机能正常裁剪,有的手机返回后直接崩,还有的干脆没有响应这个Intent的应用。如果你要求不高,只想裁剪成正方形,系统自带裁剪确实是最省事的方案,因为不需要引入任何第三方依赖。
我的建议是分场景来看:如果是临时Demo、验证功能,用系统裁剪就行;如果要上生产、面对各种机型,最好引入uCrop这类成熟裁剪库,或者干脆在原生端写一个简单的裁剪页面。篇幅原因,这篇文章先讲系统裁剪的完整方案,因为它的代码最少、最容易理解,把链路跑通之后再换uCrop也只是替换一个Activity的事。
1.3 整体流程怎么串
完整链路是这样的:Creator JS层调一个接口 -> Android原生端弹出相机或相册 -> 用户选择图片 -> 原生端调起系统裁剪 -> 裁剪完成后把图片保存到本地文件 -> 将文件绝对路径回传给JS层 -> JS层用这个路径构造文件流上传到服务器 -> 服务器返回图片URL -> JS层根据URL下载图片并保存到本地沙盒目录用于展示。
这个流程里最需要重点处理的就是那几次数据传递:第一次是JS到原生的参数传递,第二次是原生到JS的路径回传,第三次是上传时的文件构造。每一步都踩过坑,下面依次展开。
2. Android原生端:相机、相册、裁剪的实现细节
先说明我用的环境:Android Studio 4.2,Cocos Creator 3.x,不过这段原生逻辑在2.x下基本也能通用,差异点我会单独标出来。
2.1 权限和FileProvider绕不过去的坑
在Android 6.0以上,相机和相册都是危险权限,必须动态申请。相册权限在Android 13上又做了细化,要分READ_MEDIA_IMAGES,如果你还在用老的READ_EXTERNAL_STORAGE,在13上系统会静默拒绝。所以权限这块建议直接同时申请这两个权限,低版本会自动忽略不存在的权限。
然后就是FileProvider。Android 7.0之后,应用之间传递file://协议的Uri会直接抛FileUriExposedException,所以相机拍照时传给相机的Uri、裁剪时传给裁剪应用的Uri,都必须走content://协议,通过FileProvider.getUriForFile()生成。这一步不做,你会在真机上亲眼看到“相机拍照后直接闪退”的名场面。
2.2 打开相机和相册的代码写法
我的做法是写了一个ImagePickerBridge类,用静态方法暴露给JS层调用。打开相册用ACTION_GET_CONTENT,打开相机用ACTION_IMAGE_CAPTURE,并且给相机指定一个输出Uri。这里有个细节:拍照时的Uri必须先通过FileProvider生成,并记得在Intent上加上FLAG_GRANT_WRITE_URI_PERMISSION,否则相机写不进去。
public static void openImagePicker(String source) { Activity activity = CocosActivity.getActivity(); Intent intent = new Intent(); if ("camera".equals(source)) { intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE); File dir = new File(activity.getExternalFilesDir(null), "picker"); if (!dir.exists()) dir.mkdirs(); File imgFile = new File(dir, "source_" + System.currentTimeMillis() + ".jpg"); Uri fileUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", imgFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); mSourceUri = fileUri; } else { intent.setAction(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); } activity.startActivityForResult(intent, REQUEST_SOURCE); }注意我把拍照生成的临时文件放到了getExternalFilesDir(null)下面,也就是App自己的外部私有目录,不需要额外存储权限,而且相册App也能正确访问。这比直接写到公共相册目录省去很多权限麻烦。
2.3 调起系统裁剪与结果处理
相机或相册选择完成后,onActivityResult会回调。这时拿到原始图片的Uri,先判断是不是content://,如果是file://也要转一次,因为裁剪应用不认file://。然后调起系统裁剪,设置宽高比,输出尺寸不要太大,不然在低端机上会OOM。
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode != Activity.RESULT_OK) { sendToScript("cancel"); return; } if (requestCode == REQUEST_SOURCE) { Uri sourceUri = (data != null && data.getData() != null) ? data.getData() : mSourceUri; startCrop(sourceUri); } else if (requestCode == REQUEST_CROP) { sendToScript(mCropResultFile.getAbsolutePath()); } } private void startCrop(Uri sourceUri) { 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", 512); cropIntent.putExtra("outputY", 512); cropIntent.putExtra("return-data", false); File dir = new File(CocosActivity.getActivity().getExternalFilesDir(null), "crop"); if (!dir.exists()) dir.mkdirs(); mCropResultFile = new File(dir, "crop_" + System.currentTimeMillis() + ".jpg"); cropIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(mCropResultFile)); cropIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); cropIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); CocosActivity.getActivity().startActivityForResult(cropIntent, REQUEST_CROP); }这里Uri.fromFile看着危险,但实际测试中它是给裁剪应用自己写文件用的,很多裁剪实现并不受FileProvider限制,不过为了避免极少数机型的问题,你也可以用FileProvider再包一层,效果一样。我的建议是保持简单,先把链路跑通再说。
3. Creator侧调用原生并拿到结果
原生端写好了,现在回到Creator这边。如果你用的是2.x,调用原生静态方法靠jsb.reflection.callStaticMethod;到了3.x,官方推荐用Native.reflection或者native.reflection。我以3.x为例写调用封装。
3.1 JSB桥接的基本姿势
callStaticMethod的签名规则是这样的:第一个参数是包名加类名,第二个是方法名,第三个是方法签名,后面接实际参数。比如要调用上面那个openImagePicker(String)方法:
import { Native } from 'cc'; let source = 'camera'; Native.reflection.callStaticMethod( 'com/example/game/ImagePickerBridge', 'openImagePicker', '(Ljava/lang/String;)V', source );(Ljava/lang/String;)V就是方法签名,Ljava/lang/String;表示String参数,V表示无返回值。如果你传int,就用I,boolean用Z。这个签名写错不会报编译错误,只会在运行时空指针,排查起来特别隐蔽,我第一次写反了,卡了整整一下午。
3.2 封装一个统一的ImagePicker模块
拿到图片路径后,原生端通过桥接回调JS层。我们约定:原生端成功返回图片绝对路径,失败或取消返回cancel或error:xxx。在Creator里封装一个ImagePicker.ts:
export class ImagePicker { public static pick(source: 'camera' | 'album', onSuccess: (path: string) => void, onFail: (msg: string) => void) { // 注册全局回调函数 (globalThis as any).__onPickImageResult = (result: string) => { if (result === 'cancel') { onFail('用户取消'); } else if (result.startsWith('error:')) { onFail(result.substring(6)); } else { onSuccess(result); } }; // 调用原生 let method = source === 'camera' ? 'openCamera' : 'openAlbum'; Native.reflection.callStaticMethod( 'com/example/game/ImagePickerBridge', method, '()V' ); } }原生端回调JS层,我用的方式是:在Java代码里通过CocosHelper.runOnGameThread回到游戏线程,再调用JsbBridge.sendToScript,把结果字符串传给JS层。这是3.x里比较顺的做法,2.x可以用Cocos2dxHelper.runOnGLThread,原理一样,就是把一段代码丢回给游戏线程执行。
3.3 文件路径与沙盒权限说明
裁剪完成返回的路径是/storage/emulated/0/Android/data/你的包名/files/crop/xxx.jpg,这是App外部私有目录。这个目录的好处是不需要存储权限就能读写,坏处是:如果你之后要把这个文件移动到自己的沙盒目录,JS层直接操作时要注意Creator的safeLocalStorage等API根本不会覆盖这里,上传时用文件流读就行,不需要JS层自己readFile。
真正需要小心的是:不要试图在JS层用字符串拼接的方式去访问这个文件,也别指望cc.assetManager能直接加载它。上传走文件流,展示走Texture2D加载本地路径,各管各的,不要混着用。
4. 图片上传与下载的实现
拿到裁剪后的图片路径,接下来的重头戏是上传和下载。Creator虽然没有暴露完整的文件系统API,但浏览器标准里的XMLHttpRequest和FormData在原生端是能用的,这就够我们完成上传下载了。
4.1 上传:FormData封装与请求头陷阱
很多人在原生环境里把XMLHttpRequest当成Web端用,其实基本没区别,直接FormData.append('file', {...})即可。在Cocos Creator原生端,FormData支持一种特殊的对象写法,携带filePath字段。这个字段是你本地文件的绝对路径,引擎会帮你读文件并构造文件流,省去手动转Base64的麻烦。
export function uploadImage(filePath: string, url: string, callback: (err: string | null, data?: any) => void) { const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.responseType = 'text'; const formData = new FormData(); formData.append('file', { filePath: filePath, name: 'avatar_' + Date.now() + '.jpg', type: 'image/jpeg', size: 0 // 某些版本要求有size,可以填0 }); xhr.onreadystatechange = () => { if (xhr.readyState !== 4) return; if (xhr.status >= 200 && xhr.status < 300) { try { callback(null, JSON.parse(xhr.responseText)); } catch (e) { callback('返回数据不是合法JSON'); } } else { callback('HTTP状态码: ' + xhr.status); } }; xhr.onerror = () => callback('网络错误'); xhr.send(formData); }这里你最可能踩的坑是:手动设置Content-Type头。如果你写了xhr.setRequestHeader('Content-Type', 'multipart/form-data'),那就等着服务器那边报解析失败吧。因为multipart/form-data必须带一个boundary分隔符,这个分隔符是浏览器引擎自动生成的,你手动设置就等于写死了一个没有boundary的头,服务器根本不知道哪里是分界。这个头让引擎自己加就行。
4.2 下载:二进制流写本地
下载的思路是:用XMLHttpRequest以arraybuffer方式拉取服务器图片,然后把二进制数据写入本地沙盒目录。文件操作可以用jsb.fileUtils,2.x和3.x都保留了这个工具,使用writeDataToFile最省事。
export function downloadImage(url: string, saveDir: string, callback: (err: string | null, localPath?: string) => void) { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.responseType = 'arraybuffer'; xhr.onload = () => { if (xhr.status !== 200) { callback('下载失败,状态码: ' + xhr.status); return; } const data = xhr.response; const fullPath = saveDir + 'avatar_' + Date.now() + '.jpg'; const fs = jsb.fileUtils; if (!fs.isDirectoryExist(saveDir)) { fs.createDirectory(saveDir); } const ok = fs.writeDataToFile(data, fullPath); if (ok) { callback(null, fullPath); } else { callback('写入文件失败'); } }; xhr.onerror = () => callback('网络错误'); xhr.send(); }需要注意的是,下载目录最好放在jsb.fileUtils.getWritablePath()下面,这是引擎给你提供的可写目录。如果你放到外部私有目录,虽然能写,但是跨平台复用就会出问题,iOS上路径规则完全不同。
4.3 和裁剪联动的一套完整流程
搭好上传和下载的基础能力后,整个业务流程就可以串起来了。用户点击头像 ->ImagePicker.pick('album')-> 拿到裁剪后的路径 -> 先压缩(如果裁剪尺寸不够小) ->uploadImage-> 服务器返回URL -> 存储到玩家存档 -> 下次进入游戏时请求服务器获取URL ->downloadImage拉取到本地 -> 用这个本地路径去创建Texture2D并设置给头像Sprite。
如果你玩的是单机或弱联网游戏,可以不考虑服务器,直接把裁剪后的图片路径存到本地存档,下次启动直接读取。但大多数游戏都要跟账号走,所以服务器存储还是得做。这里我就不展开后端代码了,不同语言实现不同,你只要保证后端能接收multipart/form-data的file字段,并能返回图片URL即可。
5. 常见问题与排查实录
最后把我在真机和模拟器上实际遇到的问题列出来,这些是真的能省你半天时间的东西。
5.1 相册选完没反应或者直接崩
症状:用户从相册选了一张图,然后没有任何反应,甚至直接闪退。排查方向有三个:一是检查FileProvider配置,res/xml/file_paths.xml里是否声明了external-files-path,并且路径匹配;二是检查机型对系统裁剪Intent的支持情况,一些厂商ROM干脆没有一个应用能响应这个Intent,这种情况只能在原生端做一个兜底,比如监测到ActivityNotFoundException就提示用户不支持裁剪,或者改用uCrop;三是检查你在onActivityResult里有没有处理data == null的情况,很多时候相册返回时Intent的data确实是null,这种情况多见于直接拍照。
提示:拍照返回的
data为null是正常现象,因为照片写到了你指定的Uri里,不要指望通过data.getData()拿到它。相册返回的data基本能拿到Uri,但部分机型在云相册里选图时,返回的是content://的下载链接,如果直接交给裁剪很可能超时。
5.2 大图OOM
症状:裁剪或者加载一张几千万像素的照片时,APP直接闪退。原因是系统默认的图片解码会按原始尺寸分配内存,一张4000x3000的图片解码出来就要占用约48MB内存,再加上游戏本身的内存占用,不OOM才怪。解决方法是在裁剪前先做一次BitmapFactory.Options.inJustDecodeBounds采样,算出合适的inSampleSize,把图片压缩到适合裁剪的尺寸。或者在裁剪Intent里把outputX、outputY设置得小一些,比如512,这样裁剪应用在内部处理时会做缩放。两个都做,最稳。
5.3 JSB调用无效、不回调报错
症状:在Creator里调Native.reflection.callStaticMethod没有任何反应,控制台也不报错。这种问题九成是方法签名写错了或者包名类名不对。你可以先写一个最简单的原生方法,只有一个无参无返回值的测试方法,比如public static void test() {},签名写成()V,先在JS层调通,再逐步加参数。如果还是不行,检查Android工程的混淆配置,发布release包时一定要在proguard-rules.pro里把ImagePickerBridge整个类keep住,否则方法名被混淆成a、b,JS层就找不到方法了。
-keep class com.example.game.ImagePickerBridge { *; }5.4 上传失败的各种状态码
上传时不成功,要从两个方向排查:如果看到HTTP 413,说明服务器限制了上传大小,裁剪输出尺寸尽量控制在512以内;如果看到HTTP 415,大概率是你手动设置了错误的Content-Type,把那段设置头的代码删掉;如果是网络层直接报错,注意检查你的App是否配置了usesCleartextTraffic=true,因为调试阶段服务器往往是http://,而Android 9.0以上默认禁止明文流量,真机死活请求失败,打包时也没人告诉你。
注意:如果你面向的是局域网调试,记得在
AndroidManifest.xml的<application>标签里加上android:usesCleartextTraffic="true",不然灰度打包后你会被这条规则折磨一整天。
写到这里,我自己再回头看这套实现,其实真正核心的东西没有多少,无非就是原生端那几个Intent的编排加上JSB桥接,再加上XHR的文件流操作。但要把这条链路调通,尤其是不踩那些机型差异和权限坑,确实需要一些经验储备。我建议你先用最小可用版本把链路跑通,再逐步完善各种边界情况和机型适配,这样心态会稳很多。最后再分享一个小技巧:拍照返回的Uri,先复制一份到App自己的cache目录再交给裁剪,很多厂商ROM在Uri接管上的奇奇怪怪问题都能绕过去。
本文还有配套的精品资源,点击获取