
简介一套以Cocos Creator为框架面向Android端实现调用系统相机/相册、裁剪图片并上传下载的完整解决方案覆盖权限管理、Intent交互、图片处理、文件操作与网络请求等关键知识点适合需要为游戏或App快速接入用户头像功能的Cocos Creator开发者参考新手与进阶开发者均可按需提取。资源共321个文件压缩包仅5.12MB包含147个json配置、86个rawproto协议文件、9个jar库与9个java源码、2个aar扩展库、8个png图片资源、7个xml配置并附带gradle构建脚本、Demo-release.apk及Creator工程文件main.cpp、helloworld.fire等原生Android模块与Creator层配置均有呈现。已有2439人学习下载。通过AvatarManager等封装可完整走通从相机/相册选择、onActivityResult回调、系统裁剪、Bitmap压缩存盘到OkHttp上传头像、以及URL下载头像的完整链路工程结构清晰详细包含AndroidManifest权限声明与运行时动态请求、启动相机相册的Intent参数配置、裁剪尺寸与比例控制、图片文件读写等可直接复用的代码细节辅以可安装APK进行体验并可直接导入工程运行或二次开发对理解Android原生与Creator混合打包的落地实现很有帮助。 做Cocos Creator打包Android的时候迟早会碰到一个绕不开的需求点一个按钮拉起系统相机拍张照或者从相册选一张图裁剪完之后传给服务器下次打开再下载回显。这个需求听起来简单但真正在真机上跑一遍才会发现从权限弹窗到图片路径再到上传下载每一层都有坑。这篇文章我把在Creator 2.x/3.x环境下实现Android原生相机相册调用、裁剪、上传下载的完整方案拆开来讲包含原生侧代码怎么组织、JSB通信怎么写、相册返回的Uri怎么转成能上传的文件、大图怎么压、上传下载怎么对接。适合用Creator做原生Android包又不想在原生层投入太多精力的开发者参考。1. 为什么绕不开原生层Creator调用Android相机的技术路线选择Cocos Creator本身运行在原生平台时对外设的封装非常有限。你在Web端可以用input typefile capturecamera这种HTML标签调起相机但打包成APK之后运行的是JavaScript引擎加OpenGL渲染环境并不是WebView所以这套网页逻辑在原生环境完全不生效。想调起系统相机、相册、裁剪本质上就是调用Android的Activity和Intent能力这些只有原生代码能做。所以技术路线基本就是三种方案优点缺点纯Creator WebView壳前端逻辑统一包体多一个WebView原生体验差权限和文件处理绕弯路原生模块 JSB反射原生能力完整包体干净需要写一部分Java/Kotlin代码涉及理解JSB第三方插件商店买现成的集成快定制成本高出了问题不好改对Creator版本兼容约束多我最终选了第二种自己写原生模块。核心原因是系统相机、相册、裁剪本身就是Activity加Intent封装起来并不复杂而且后续如果要做多图选择、视频录制、拍照识别都是同一套架构上继续加方法就行。整个链路的数据流向是这样Creator的JS层发现用户点了换头像按钮通过JSB反射调用Android原生的静态方法原生层调起相机或相册Intent拿到图片后走系统裁剪Activity裁剪完成把图片保存到应用私有目录再回调Creator的JS方法告诉它文件路径。后续的上传下载在JS层用HTTP请求完成也可以在原生层封装上传接口。按这个思路每个环节的职责很清楚原生只负责拿到一张裁剪好的图片其他事都归上层管。2. Android原生侧实现拍照、选图与裁剪的三个关键设计2.1 权限配置与FileProvider不配好直接闪退相机和相册涉及的权限主要有CAMERA、READ_EXTERNAL_STORAGEAndroid 13及以上还要注意新的READ_MEDIA_IMAGES但为了保证低版本兼容Manifest里可以这样写uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 /这一层很容易被忽略因为Android 6.0以后动态权限需要在运行时申请打包进APK不至于崩溃但真机上调用相机时如果没有权限直接SecurityException。我的做法是在原生Activity里用原生API判断权限权限没通过就先申请回调到JS层告诉用户刚才拒绝了相机权限。FileProvider这一关是另一个大坑。Android 7.0开始应用之间传递file://Uri会直接抛FileUriExposedException所以系统相机的拍照、裁剪Intent都必须走content://Uri也就是用FileProvider生成临时Uri。配置分两处先在Manifest的application标签里注册Providerprovider 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.xmlpaths cache-path namecache path. / external-files-path nameexternal_files path. / /paths这样配置之后应用私有目录下产生的图片文件才能通过FileProvider对外授权给系统相机和裁剪页面。authorities里的${applicationId}在Cocos工程里通常可以直接写包名也可以用构建时占位符自动替换我用占位符更省心。2.2 拍照和选图的Intent差异Uri授权方式完全不同启动系统拍照Intent是这样的Intent intent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); File photoFile createImageFile(); // 在cacheDir或filesDir创建临时jpg文件 Uri photoUri FileProvider.getUriForFile(context, context.getPackageName() .fileprovider, photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); startActivityForResult(intent, REQUEST_TAKE_PHOTO);重点在于EXTRA_OUTPUT必须是一个content://Uri而且尽量把写权限也授予出去否则部分ROM的相机应用拍完写不回来。照片是系统相机直接写入这个Uri指向的文件拍完从onActivityResult的data里取不到图图在之前创建的那个photoFile里。相册选图我推荐用ACTION_GET_CONTENT别用ACTION_PICK。ACTION_GET_CONTENT是通用的文档选择入口兼容性最好用户可以顺便从文件管理器、网盘里选图。但它返回的Uri通常是临时的content://Uri授权只在这个Intent结果有效期内有效一旦后面裁剪Activity要再次访问这个Uri可能因为权限不足崩掉。所以稳妥做法是拿到Uri之后立刻用ContentResolver复制到自己应用缓存目录再把这个新文件交给裁剪流程Intent intent new Intent(Intent.ACTION_GET_CONTENT); intent.setType(image/*); startActivityForResult(intent, REQUEST_PICK_IMAGE);在onActivityResult里对REQUEST_PICK_IMAGE的处理我会统一走一个方法把uri转成输入流然后copy到cacheDir返回一个本地绝对路径后续所有操作都基于这个本地文件。这样等于把所有外部来源的图都在自己地盘上固定下来后续不受系统授权影响。2.3 裁剪Intent的写法与系统裁剪不可靠问题裁剪Intent在AOSP里有名义上标准的写法它叫com.android.camera.action.CROP但不同厂商的ROM实现差异很大。我的标准写法是这样Intent intent new Intent(com.android.camera.action.CROP); intent.setDataAndType(Uri.fromFile(new File(inputPath)), image/*); intent.putExtra(crop, true); intent.putExtra(aspectX, 1); intent.putExtra(aspectY, 1); intent.putExtra(outputX, 512); intent.putExtra(outputY, 512); intent.putExtra(return-data, false); Uri outputUri FileProvider.getUriForFile(context, context.getPackageName() .fileprovider, cropFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);这里有两个细节值得单独说。第一return-data必须设置成false。如果设成true裁剪后的Bitmap会通过Intent的data字段回传小图还好大图会直接导致Binder transaction失败甚至OOM。设成false之后裁剪输出由MediaStore.EXTRA_OUTPUT指定到我们自己的文件里onActivityResult经常拿到的data是空的这属于正常现象别在那纠结。第二系统裁剪Activity并非所有手机都有特别是部分小米、华为ROM会砍掉这个隐式Intent所以启动前最好判断有没有Activity能响应if (intent.resolveActivity(context.getPackageManager()) null) { // 返回错误信息给Creator层或直接跳过裁剪用原图 }真机表现不好我后来的做法是在原生层做一个兜底判断如果系统没有裁剪能力就把原图压缩后直接返回给Creator并标记“未裁剪”。这样总比整个流程崩掉强。3. Cocos Creator侧调用JSB反射与线程切换的正确姿势3.1 原生侧暴露一个干净的静态方法在Cocos工程里通过Android Studio打开proj.android找到AppActivity类。不同Creator版本位置略有差别2.x时代在org/cocos2dx/javascript/AppActivity.java3.x打包出来包名结构可能不同但核心都是把这个类当成入口。我在这个类里加一个静态方法public static void openImagePicker(int pickType, final String callback) { // pickType 1表示拍照2表示相册3表示直接裁剪 // callback 是Creator侧注册的全局回调方法名比如 nativeCallback }为什么不直接在AppActivity里堆所有逻辑因为拍照、选图、裁剪三个流程的代码量不少放在一起很乱。我的做法是单独抽一个ImagePickHelper工具类AppActivity只负责转调这样以后要加多图选择或者视频录制都是在Helper里加方法Activity保持干净。3.2 JS层调用与回调协议设计Creator侧只有一行核心调用if (sys.isNative sys.os sys.OS.ANDROID) { jsb.reflection.callStaticMethod( org/cocos2dx/javascript/AppActivity, openImagePicker, (ILjava/lang/String;)V, 1, nativeOnPickResult ); }这里最容易出错的是方法签名。Java方法参数是int和StringJNI签名就是(ILjava/lang/String;)V包名路径要用斜杠分隔类名不带.class。我见过很多朋友把这行写错导致调用没反应又不报错排查半天。建议在原生方法里第一时间加Log能看到就被调用了看不到就是签名或类名写错了。回调协议我统一用JSON字符串原生侧回调private void callbackToJs(String json) { runOnUiThread(new Runnable() { Override public void run() { CocosHelper.runOnGameThread(new Runnable() { Override public void run() { // 这里执行 jsb.reflection.callStaticMethod 调JS全局方法 String jsCall if(window. callbackName ) window. callbackName ( json );; Cocos2dxJavascriptJavaBridge.evalString(jsCall); } }); } }); }成功的回调格式是{code:0,path:/data/user/0/xxx/files/tmp_123.jpg}用户取消是{code:-1,msg:cancel}失败是{code:-2,msg:...}。为什么用JSON而不是传多个参数因为后期增加字段比如图片旋转角度、宽度高度、原始文件大小都不需要改方法签名Creator侧解析一下就行。3.3 线程问题所有JS调用必须回到游戏线程这个坑我踩得特别深必须单独拿出来讲。onActivityResult返回时是在Android的UI线程如果在这个线程直接调Cocos2dxJavascriptJavaBridge.evalString绝大多数情况会闪退而且报错信息不明显很多是直接SIGSEGV。正确做法是先runOnUiThread确保原生逻辑在UI线程执行完再切到runOnGameThread去调JS。这个规则适用于iOS之外的所有平台Android尤其严格。封装回调方法时一定把线程切换写进去不然留存一堆时好时坏的问题非常难查。另外在Creator 3.x里面推荐用引擎提供的native.callToJS或者JsbBridge做事件转发特别是你在用官方的新架构时事件监听比evalString更规范。老项目继续用evalString问题也不大但一定要在GL线程执行。线程切不对再好的代码都是废的。4. 从Uri到可上传文件ContentResolver、图片旋转与压缩的完整链路4.1 不要迷信获取真实路径以前很多老博客教的是查MediaStore的DATA字段得到真实文件路径String[] projection {MediaStore.Images.Media.DATA}; Cursor cursor context.getContentResolver().query(uri, projection, null, null, null);这个方法在Android 10之前能用但Android 10之后分区存储时代彻底失效DATA列经常返回null就算返回了一个路径也不保证应用能直接读。所以我的做法很简单不管相册返回的Uri是什么来源一律用ContentResolver打开输入流复制到应用自己的目录纯粹靠文件内容干活不依赖任何系统路径字段。InputStream is context.getContentResolver().openInputStream(uri); OutputStream os new FileOutputStream(tempFile); byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { os.write(buffer, 0, len); }这种做法最省心无论是相册、文件管理器选出来的图还是其他App分享过来的图都能统一处理。4.2 拍照后图片旋转90度的问题Android相机的传感器方向导致拍出来的照片经常自带EXIF旋转信息直接按像素读取的话竖着拍的照片显示出来是横着的。这个问题用ExifInterface就能解决ExifInterface exif new ExifInterface(filePath); int orientation exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); Matrix matrix new Matrix(); switch (orientation) { case ExifInterface.ORIENTATION_ROTATE_90: matrix.postRotate(90); break; case ExifInterface.ORIENTATION_ROTATE_180: matrix.postRotate(180); break; case ExifInterface.ORIENTATION_ROTATE_270: matrix.postRotate(270); break; } Bitmap rotated Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);但注意相册选图经过系统裁剪之后很多ROM会把EXIF信息清掉这时候就算有旋转也不影响因为rotation已经写进像素了。所以旋转处理要放在压缩之后、上传之前统一执行EXIF存在就转不存在就不转避免重复旋转。4.3 大图压缩策略先采样再压缩相册里选出来的图动不动几兆十几兆直接塞给服务器既不现实解码成Bitmap还经常OOM。我的压缩流程分两步。第一步用BitmapFactory.Options的inJustDecodeBounds读宽高按目标尺寸1024计算采样率BitmapFactory.Options opts new BitmapFactory.Options(); opts.inJustDecodeBounds true; BitmapFactory.decodeFile(filePath, opts); int sampleSize 1; while (opts.outWidth / sampleSize 1024 || opts.outHeight / sampleSize 1024) { sampleSize * 2; } opts.inJustDecodeBounds false; opts.inSampleSize sampleSize; Bitmap bitmap BitmapFactory.decodeFile(filePath, opts);第二步用质量压缩写回文件JPEG格式85%质量肉眼基本无感体积能小很多ByteArrayOutputStream baos new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 85, baos); FileOutputStream fos new FileOutputStream(outputFile); fos.write(baos.toByteArray()); fos.flush(); fos.close();整个过程下来一张相册原图通常能被压到200KB以内上传速度快得多。之前我见过直接解码原图导致内存飙升到几百MB的案例就因为在Creator层用cc.loader.load加载一张4000x3000的图片完全没有做预处理。记住一个原则上传前源数据在原生层处理好不要在JS层反复折腾大图。4.4 文件放哪里cacheDir和filesDir的取舍图片处理完之后的落盘位置值得花时间想清楚。应用私有目录里有两个候选cacheDir适合存放临时裁剪文件应用被系统清理或者版本升级时可能被删不适合长期保存。filesDir适合存放用户最终要用的图比如头像、聊天图片重启后还在卸载才消失。我的建议裁剪过程中的中间文件全放cacheDir裁完上传成功之后如果需要本地缓存再从服务器下载到filesDir。千万别把服务器返回的下载路径存成/data/user/0/包名/cache/...这种绝对路径因为Android更新或者清理缓存后cacheDir路径可能变下次再用就找不到了。5. 上传与下载客户端代码、服务端接口约定与路径管理的取舍5.1 上传用OkHttp在原生层搞定别在JS层拼FormData图片上传我试过两种方案一种是拿到路径后在Creator侧用XMLHttpRequest拼FormData另一种是原生层用OkHttp发Multipart。实际体验下来原生层上传更稳原因有三个Native环境下XMLHttpRequest的Blob支持有兼容问题处理大文件时行为不稳定。原生层可以直接File对象省去JS层读文件、转二进制、拼边界符的麻烦。后续要显示上传进度OkHttp有现成的RequestBody包装方式JS层不太好做。OkHttp上传核心就这几行OkHttpClient client new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .build(); File file new File(filePath); RequestBody fileBody RequestBody.create(MediaType.parse(image/jpeg), file); MultipartBody body new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart(file, fileName, fileBody) .build(); Request request new Request.Builder() .url(uploadUrl) .post(body) .build();请求结果仍然是回调给JS层成功返回{code:0,url:https://...}失败返回{code:-10086,msg:...}。上传放到子线程执行回调用之前说好的JSON协议通知Creator。5.2 服务端接口约定字段越简单越好服务端我用SpringBoot写的时候接口就是这么约定的接收一个multipart/form-data的file字段存储后返回URL。客户端不要和服务端搞复杂的格式协商有一个稳定约定就够了POST /api/file/upload Content-Type: multipart/form-data 字段名: file 响应: {code:0,data:{url:https://cdn.xxx.com/xxx.jpg}}下载就简单了图片是静态资源服务器给个GET地址就能拿。需要验证权限的业务场景可以做带签名URL否则直接公开访问最省事。客户端拿到URL后用cc.assetManager.loadRemote直接加载显示这一层不用自己写下载逻辑assetManager.loadRemote(url, { ext: .jpg }, (err, texture) { if (!err texture) { this.sprite.spriteFrame new SpriteFrame(texture); } });5.3 离线缓存的设计文件名以服务端ID为准如果用户改了头像之后希望下次打开App在没有网络的情况下也能看到上次的头像就需要本地缓存。我的做法是服务端返回文件URL时附带一个fileId客户端下载到filesDir下文件名用avatar_{userId}_{fileId}.jpg这种格式。每次进入页面先检查本地有没有这个文件有就直接用本地路径加载没有就loadRemote下载再落盘。这里有个细节加载本地文件路径时如果文件名带空格或者中文需要先encodeURI一下否则Android原生加载路径会有问题。这个坑我是在真机测试时发现的文件路径一长就加载失败排查半天才发现是URL编码问题。5.4 上传进度和失败重试不能省测试环境没人关心进度条到生产环境用户就会抱怨点了一下没反应到底传没传。OkHttp上传进度可以用自定义RequestBody包装重写writeTo计算已写入字节数然后用回调通知JS层。我在项目里是每1%回调一次太频繁的话JS层调度会卡。失败重试的策略更务实连续失败两次就弹出错误提示不要无限重试因为头像图片这类小文件失败大概率是网络环境问题无限重试只会让用户等更久。6. 高频报错与排查覆盖我踩过的那些真机坑这一节列出的每一条都是真实设备上出过的问题。我把它整理成表格排查的时候照着对就能省很多时间。报错现象根因解决办法FileUriExposedExceptionAndroid 7.0以后禁止file://跨应用传递全部改用FileProvider生成content://UriPermission Denial: opening provider启动相机/裁剪Intent时未授予Uri权限给Intent加FLAG_GRANT_READ_URI_PERMISSION和FLAG_GRANT_WRITE_URI_PERMISSION裁剪Activity返回后data为空return-datafalse时data本来就可能为空从EXTRA_OUTPUT指定的Uri读取结果图transaction too large / OOMreturn-datatrue把大Bitmap塞进Intent设置return-datafalse输出到文件MediaStore的DATA列返回nullAndroid 10分区存储后此字段不可用用ContentResolver.openInputStream复制文件拍出来的照片横了EXIF里写了rotation显示时没处理ExifInterface读TAG_ORIENTATIONMatrix旋转JS层收不到原生回调在UI线程调了evalString或方法名不一致用runOnGameThread切回游戏线程回调函数名统一相册原图加载直接OOM没有采样直接decodeFile用inJustDecodeBounds inSampleSize采样小米/华为部分机型没有裁剪ActivityROM删除了com.android.camera.action.CROPresolveActivity判断不能用系统裁剪时返回原图或集成uCrop排查链路比技巧本身更重要。我的习惯是先在Android Studio里直接运行原生工程让Cocos打包后的安卓工程输出到本地然后通过Logcat持续观察。原生侧在进入方法、启动Intent、onActivityResult返回这三个关键节点各打印一条日志JS侧在回调入口也打印一条日志用日志时间戳比对就能快速定位问题出在原生还是JS层。曾经有个问题卡了我两天最后发现是库冲突导致FileProvider的authorities重复系统直接判断Provider不可用。这种问题看报错日志根本看不出来必须要从Manifest合并后的最终文件入手检查。另外提一句如果你想跳过系统裁剪那些神一样的兼容问题直接在应用内集成uCrop是一个更稳的选择。uCrop自带完整裁剪界面适配了国内各种ROM缺点是需要额外引入依赖库包体会大一点。我的看法是如果只是头像裁个正方形系统裁剪够用如果业务方要求圆形裁剪、自由比例、滤镜这些花活儿别犹豫直接uCrop。按这套架构跑下来目前我在多个Creator版本、几十台真机上验证过核心逻辑没有大改过。原生层始终定位成拍照、选图、裁剪、压缩文件的工具箱所有业务逻辑都归Creator侧调度回调协议一直保持JSON字符串。以后要是需要支持多图上传在Helper里加个遍历方法就行不需要动架构。如果只是做个单纯的头像更换这个方案完全够用要是想做更复杂的多媒体功能这个框架也能继续往上长。我最想强调的是原生和Creator之间的协议一定要提前定好不然改一次架构就要连带改一遍回调代价太大了。本文还有配套的精品资源点击获取