ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos Creator安卓图片选择裁剪上传下载完整指南

Cocos Creator安卓图片选择裁剪上传下载完整指南 简介面向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 typefile就够了浏览器自动帮你搞定相机相册选择。但打包成APK之后Web能力是被裁剪过的JS层没有可以安全弹出系统选择器的接口更别提裁剪了。你当然可以用Canvas弯道超车让用户选完图之后自己在游戏里拖拽裁剪框但那种交互体验和系统裁剪比差太远而且游戏画面的输入事件本身就和原生控件的尺寸、坐标系有冲突调试成本非常高。所以我最终选择的是原生扩展方案在Android原生端把相机、相册、裁剪这三个动作全做了然后把裁剪后的图片路径通过桥接层回传给Creator的JS层由JS层负责上传和下载。这样做的好处是原生端的代码逻辑稳定、权限处理成熟裁剪界面对用户来说也是最熟悉的系统界面不用自己画UI。1.2 裁剪方案怎么选裁剪是这里最容易出坑的一环。Android系统自带的裁剪Intentcom.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.2Cocos 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就用Iboolean用Z。这个签名写错不会报编译错误只会在运行时空指针排查起来特别隐蔽我第一次写反了卡了整整一下午。3.2 封装一个统一的ImagePicker模块拿到图片路径后原生端通过桥接回调JS层。我们约定原生端成功返回图片绝对路径失败或取消返回cancel或error:xxx。在Creator里封装一个ImagePicker.tsexport 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.fileUtils2.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、bJS层就找不到方法了。-keep class com.example.game.ImagePickerBridge { *; }5.4 上传失败的各种状态码上传时不成功要从两个方向排查如果看到HTTP 413说明服务器限制了上传大小裁剪输出尺寸尽量控制在512以内如果看到HTTP 415大概率是你手动设置了错误的Content-Type把那段设置头的代码删掉如果是网络层直接报错注意检查你的App是否配置了usesCleartextTraffictrue因为调试阶段服务器往往是http://而Android 9.0以上默认禁止明文流量真机死活请求失败打包时也没人告诉你。注意如果你面向的是局域网调试记得在AndroidManifest.xml的application标签里加上android:usesCleartextTraffictrue不然灰度打包后你会被这条规则折磨一整天。写到这里我自己再回头看这套实现其实真正核心的东西没有多少无非就是原生端那几个Intent的编排加上JSB桥接再加上XHR的文件流操作。但要把这条链路调通尤其是不踩那些机型差异和权限坑确实需要一些经验储备。我建议你先用最小可用版本把链路跑通再逐步完善各种边界情况和机型适配这样心态会稳很多。最后再分享一个小技巧拍照返回的Uri先复制一份到App自己的cache目录再交给裁剪很多厂商ROM在Uri接管上的奇奇怪怪问题都能绕过去。本文还有配套的精品资源点击获取
返回列表