简介:面向需要在安卓端实现头像选择、裁剪与上传下载功能的开发人员,这份压缩包提供了一套基于 Creator 工程的原生调用相机、相册的完整示例。包体共 321 个文件、约 5.12MB,以 JSON、rawproto 等配置类文件为主,辅以 Java/JS 源码、jar/AAR 依赖库、PNG 素材和可直接安装验证的 APK,目录结构与 Gradle 工程配置相互配套,便于按模块对照阅读和二次开发。内容覆盖了从权限声明与运行时请求、Intent 启动相机和相册、系统裁剪工具剪出固定尺寸图片,到 Bitmap 压缩保存为本地 JPEG、使用 OkHttp Multipart 上传、再通过 URL 下载回显的完整链路,并给出 AvatarManager 类封装思路,将各环节解耦成可直接迁移的接口。目前已有 2445 人学习下载,适合正在做头像上传模块、图片采集上传功能,或想了解安卓原生与 Creator 交互细节的中级开发者参考。
1. 一个头像功能引发的连锁改造:Creator 调起 Android 相机相册、裁剪、上传下载的完整闭环
做过 Creator 打包上线的开发者,多半在“上传头像”这个功能上栽过跟头:web 端一行 input 标签就能选图,打包到 Android 上却连相册都调不起来;好不容易调起来了,选完图拿到的 content:// 地址转 file:// 又报文件不存在;裁剪完图片自动转了 90 度,服务端下载回来显示位置全错。这套源码包解决的正是从 Creator 的 TypeScript 层通过 JSB 桥接去调 Android 系统相机和相册,选图后走系统裁剪,再统一做 EXIF 旋转、流式上传和本地缓存下载的完整闭环。适合正在做头像、证件照、反馈截图这类图片业务的 Creator 开发者,也适合准备把 web 版图片选择能力迁移到原生包的团队参考,照着跑通后能直接拿去改自己的业务参数。
2. 技术底座与目录结构:先看清这套代码把活分给了谁
2.1 技术选型:为什么相机相册必须交给原生层
Creator 在浏览器环境里选图,一个<input type="file" accept="image/*">就能打开系统文件选择器,图片直接以 base64 或者 Blob 形式拿到手。但同样的代码打包成 Android 原生应用后,web 那套 DOM API 在 JSB 环境里并不存在,打开相册、读取相册图片、写回临时文件这些操作全都够不着。社区里常见的做法是在原生层注册一个可被反射调用的静态类,把 Java 方法暴露给 TypeScript,这就是整套方案的核心思路:原生层负责所有系统能力,脚本层只负责把参数传进去、把结果接回来。
这个分工还带来一个容易被忽略的好处:图片的读取、旋转、压缩、上传、下载都在原生层用文件流处理,大图不会整张读进 JS 内存。用 base64 在 JS 和 Java 之间传一张 3000x4000 的照片,光字符串就四五 MB,GC 压力相当大,而传文件路径只需要几百字节。所以这套代码里,JS 与 Java 之间的协议统一设计成“传路径、传状态、传小体积数据”,图片本体永远留在原生层的文件系统里。
技术栈方面,脚本层是 Creator 2.x 的 TypeScript 加引擎自带的jsb.reflection静态方法桥接;原生层是标准 Android Java,用 Intent 调起系统相机和相册选择器,用 FileProvider 解决 Android 7.0 之后对 file:// 访问的限制,裁剪复用的是系统自带 CROP,网络层用 JDK 自带的 HttpURLConnection,不引入额外依赖。整套方案没有依赖 Creator 的第三方图片插件,适合对依赖体积比较敏感的项目。
2.2 目录结构:脚本、原生、桥接三块各管什么
拿到源码包后先别急着拖进编辑器,建议先把目录职责对应起来。这是一个典型的 Creator 2.x 项目结构,脚本放在 assets/scripts 下,原生代码放在 native/engine/android 下,桥接只发生在 PhotoHelper.ts 与 PhotoHelper.java 之间的静态方法调用上。
| 文件/目录 | 职责 | 与其它模块的关系 |
|---|---|---|
| assets/scripts/photo/PhotoHelper.ts | 对脚本层暴露 openCamera / openAlbum / crop / upload / download | 调用原生 PhotoHelper.java 的静态方法,统一管理回调编号 |
| assets/scripts/photo/PhotoUploader.ts | 上传业务封装,维护回调 Map | 接收 PhotoHelper 回传的路径,发起上传 |
| assets/scripts/photo/PhotoCache.ts | 下载与本地缓存管理 | 下载成功后返回本地路径,供头像组件直接显示 |
| native/engine/android/src/.../PhotoHelper.java | 权限申请、FileProvider、相机相册 Intent、onActivityResult 分发 | 被脚本层静态调用,处理结果统一缓存在应用缓存目录 |
| 同目录 PhotoCropActivity.java | 封装系统 CROP 裁剪 Intent | 由 PhotoHelper 启动,回传裁剪结果 |
| 同目录 PhotoUploadService.java | 原生层上传下载网络实现 | 供 PhotoHelper 调用,避免脚本层拼网络协议 |
注意:src/com/... 路径里的包名占位段,在二次开发时一定要改成你自己项目的包名,否则 AndroidManifest 里的 provider authorities 会和 FileProvider 注册对不上。
2.3 调用链路:从按钮点击到头像回显的一次完整旅程
整条链路可以拆成八步。第一步,用户在 Creator 场景里点击“更换头像”按钮,脚本层拿到本次操作期望的图片来源类型(相机或者相册)。第二步,PhotoHelper.ts 给这次操作分配一个自增 callbackId,调用jsb.reflection.callStaticMethod把类型和 callbackId 传给 PhotoHelper.java。第三步,Java 层检查运行时权限,没授权就发 ActivityCompat.requestPermissions,授权后根据 sourceType 组装对应的 Intent。
第四步,相机场景通过 FileProvider 生成一个 outputUri 传给MediaStore.ACTION_IMAGE_CAPTURE,相册场景直接发Intent.ACTION_GET_CONTENT并限定image/*。第五步,onActivityResult 收到 RESULT_OK 后,对相册来源先做 content:// 到本地文件的落盘拷贝,避免临时权限失效后地址变成死链。第六步,如果这次操作带了裁剪参数,就启动 PhotoCropActivity 走系统 CROP;不带裁剪参数的,直接把处理后的文件路径回传。第七步,PhotoHelper.java 在 UI 线程回调脚本层注册的方法,把路径和状态码带回来。第八步,脚本层在回调里拿到图片路径,缩略图直接显示,原图交给 PhotoUploader 走上传,上传成功后服务端返回头像 URL,再交给 PhotoCache 下载到本地缓存目录。
这八步里最容易出事故的是第五步和第七步,后面第 5 章会专门展开。先看脚本层入口大概长什么样:
// PhotoHelper.ts 中脚本层对外开放的入口 public static chooseAndCrop(sourceType: "CAMERA" | "ALBUM", options: CropOptions) { const cbId = PhotoHelper.nextCallbackId++; PhotoHelper.pendingCallbacks[cbId] = { success: options.success, fail: options.fail, }; if (sys.platform === sys.Platform.ANDROID) { // sourceType 映射为 1(相机)或 2(相册) const type = sourceType === "CAMERA" ? 1 : 2; jsb.reflection.callStaticMethod( "com/example/photomodule/PhotoHelper", "openPhoto", "(III)V", type, cbId, options.crop ? 1 : 0 ); } }这段代码做的事情是:给本次操作分配一个自增编号,把成功和失败回调放进一个待回调 Map,然后通过 JSB 反射调用原生静态方法openPhoto。三个参数依次是来源类型、回调编号、是否需要裁剪,全部是 int,所以方法签名写成(III)V,意思是三个 int 参数、无返回值。整段代码只在sys.platform === sys.Platform.ANDROID时生效,iOS 和 Web 走另外的分支,这是 Creator 项目里常见的平台判断写法。
3. 核心模块拆解:原生相机相册调用与 JSB 桥接是怎么协作的
3.1 原生入口:权限、FileProvider 与相机 Intent 的完整写法
PhotoHelper.java 的静态方法 openPhoto 是整个桥接的地基。相机分支不能直接 new 一个 Intent 就算完,Android 7.0 之后如果往 EXTRA_OUTPUT 塞 file:// 的 Uri 会直接抛 FileUriExposedException,所以必须先通过 FileProvider 把输出路径包装成 content:// 的临时 Uri。
public static void openPhoto(int sourceType, int cbId, int needCrop) { if (!checkAndRequestPermission(KIND_CAMERA)) { CACHE_CB_ID = cbId; return; } if (sourceType == SOURCE_CAMERA) { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(getAppContext().getPackageManager()) != null) { File outFile = createImageFile("avatar_src.jpg"); Uri outputUri = FileProvider.getUriForFile( getAppContext(), getAppContext().getPackageName() + ".fileprovider", outFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); CAPTURE_OUTPUT_URI = outputUri; getTopActivity().startActivityForResult(intent, REQ_CAMERA); } } else { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); getTopActivity().startActivityForResult(intent, REQ_ALBUM); } }这段代码有四个关键点。第一,startActivityForResult 之前先用resolveActivity判断系统里是否真的存在能处理相机 Intent 的应用,很多模拟器和定制 ROM 上这个判断能避免黑屏闪退。第二,相机拍照的输出必须指定到应用沙盒内的文件,而不是用系统返回的缩略图,因为 ACTION_IMAGE_CAPTURE 不带 EXTRA_OUTPUT 时返回的 data 里只有一张压缩过的缩略图,原图拿不到。第三,FileProvider 的 authorities 必须和 AndroidManifest 里 provider 的 authorities 完全一致,这里是“包名 + .fileprovider”的约定,不一致会在运行时直接崩。第四,相册分支用 ACTION_GET_CONTENT 而不是 ACTION_PICK,前者兼容更多文件管理器,后者只返回媒体库里的条目。
权限这块是另一个隐藏点。Android 6.0 之后相机权限属于运行时权限,不能只在 Manifest 里声明。上面代码里 checkAndRequestPermission 是个包装方法,内部用 ActivityCompat.requestPermissions 发起请求,并在 onRequestPermissionsResult 里把结果通过同一个 JSB 回调通知脚本层。需要说明的是,如果只是打开相册选图,在 Android 11 以下是不需要申请存储权限的,系统选择器会帮你完成读取;只有相机分支必须申请 CAMERA 权限。源码包里两个来源共用权限申请逻辑,实际业务里如果只在相册场景调用,可以把这个申请条件改得宽松一些。
3.2 JSB 桥接:静态方法签名是怎么对上号的
脚本层调用原生方法的关键是jsb.reflection.callStaticMethod的签名匹配。Creator 2.x 的 JSB 实现会去反射查找类路径、方法名和 JNI 签名,任何一个字符对不上都会在运行日志里表现为方法没反应或者报 undefined is not a function。所以桥接签名必须和 Java 方法签名严格一致。
public static void openPhoto(int sourceType, int cbId, int needCrop) { ... } public static void openAlbumDirect(int cbId) { ... } public static void onJsCallback(int cbId, int status, String msg, String path) { // 由 Java 层向脚本层发数据,脚本层注册一个全局函数等待接收 }对应到脚本层,调用 openPhoto 的签名是(III)V,调用 openAlbumDirect 的签名是(I)V。签名规则是参数类型逐个写在括号里,V 表示返回值 void。常见的坑是把 string 参数写成了 Ljava/lang/String 格式但漏掉分号,或者参数个数对不上导致一直静默失败。调试这类桥接问题时,我习惯先把 Java 方法改成固定返回并加一条 Log 输出,确认原生方法真的被调用到了,再去追参数签名,能省掉一半的排查时间。
JSB 的回调实现一般有两条路。一条是原生层持有 cbId,在合适的时机调用静态方法 onJsCallback,由脚本层注册一个全局函数等待接收;另一条是脚本层把回调函数名传过去,原生层通过 evaluateString 去执行一段 JS 代码。这套源码包用的是第一条,理由是回调函数作为对象跨语言边界传递时,生命周期管理容易出泄漏,而传整型 id 最安全。脚本层对应维护一个 Map,收到回调后取出成功或失败函数执行,执行完就删除,避免重复回调。
3.3 回传协议:onActivityResult 到脚本层回调的通道
onActivityResult 是原生层接手系统结果的地方,这里有两个容易忽略的细节。相册选图返回的 uri 是 content:// 开头,系统授予的是临时读取权限,这个权限在当前 Activity 还活着时有效,但如果随后打开裁剪页、或者先把路径存起来稍后再用,权限极有可能已经失效。所以源码包的做法是拿到 uri 后立刻做一次落盘拷贝,把图片完整读进自己的缓存文件。
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode != RESULT_OK) { PhotoHelper.onJsCallback(cbId, STATUS_CANCEL, "user cancel", ""); return; } if (requestCode == REQ_CAMERA) { // 拍照走的是 EXTRA_OUTPUT,结果在 CAPTURE_OUTPUT_URI 指向的文件里 String srcPath = uriToFilePath(CAPTURE_OUTPUT_URI); dispatchToNext(srcPath, cbId); } else if (requestCode == REQ_ALBUM) { // 相册返回的是临时 content uri,先拷贝再裁剪 String srcPath = copyContentUriToCache(data.getData()); dispatchToNext(srcPath, cbId); } }resultCode 不是 RESULT_OK 时,很多初学者会忘记处理,后果是用户取消选图后脚本层的回调永远不触发,界面一直卡在“等待返回”的状态。这里显式回了一个 STATUS_CANCEL,脚本层可以根据这个状态把 loading 关掉。第二个细节是拍照结果不在 data 里,而在你指定的 EXTRA_OUTPUT 文件里;data.getData() 在相机分支经常是 null,如果你统一用 data.getData() 取结果,拍照分支会直接空指针。源码包用 CAPTURE_OUTPUT_URI 记录拍照输出路径,onActivityResult 里直接从文件取,绕开了这个坑。
4. 裁剪、上传与下载:头像数据的三个落点
4.1 系统裁剪:CROP Intent 的可用性检测与输出参数
裁剪这里没有引入第三方裁剪库,直接用系统自带的 CROP Intent。好处是零依赖、包体不增加,坏处是不同 ROM 对裁剪窗的样式和参数支持程度不一样。使用前要做一次 resolveActivity 检测,部分国产 ROM 把 CROP 组件屏蔽了,直接 startActivity 会抛 ActivityNotFoundException。
public static void startCrop(Activity activity, String srcPath, int cbId, int aspectX, int aspectY) { Intent intent = new Intent("com.android.camera.action.CROP"); intent.setDataAndType(Uri.fromFile(new File(srcPath)), "image/*"); intent.putExtra("crop", "true"); intent.putExtra("aspect_x", aspectX); intent.putExtra("aspect_y", aspectY); intent.putExtra("output_x", 480); intent.putExtra("output_y", 480); intent.putExtra("return-data", false); intent.putExtra("output", Uri.fromFile(new File(getCropFilePath()))); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); if (intent.resolveActivity(activity.getPackageManager()) != null) { activity.startActivityForResult(intent, REQ_CROP); } else { // 裁剪不可用,直接回原图 PhotoHelper.onJsCallback(cbId, STATUS_CROP_FAIL, "crop not support", srcPath); } }几个参数值得单独说明。aspect_x / aspect_y 是裁剪框的宽高比,头像场景用 1:1,所以 aspect 固定传 1、1 就是正方形裁切窗。output_x / output_y 是最终输出的像素尺寸,不是裁剪框分辨率,系统裁剪后会把结果缩放到这个尺寸。头像一般 480x480 就够用,如果要高清大图可以改到 1024。return-data 必须设成 false,如果写成 true,系统会把裁剪结果以 Bitmap 形式塞进 Intent 的 data 里,大图直接把一个几 MB 的位图放 Intent 里传输,轻则内存抖动,重则 TransactionTooLargeException 直接崩掉。设成 false 后结果写到 output 指定的文件,onActivityResult 里只需要去那个文件路径读取就行。
注意这里用的是Uri.fromFile,在 Android 7.0+ 上 CROP 组件读取这个 Uri 时,实际是系统裁剪应用自己去访问,很多系统裁剪应用依然兼容 file://,但部分新机型已经不行。如果打算长期维护,建议把裁剪也包成 FileProvider Uri,代码里同时保留两种路径处理逻辑,用 try-catch 兜底,裁剪失败时返回原图而不是让用户卡住。
4.2 上传:原生层 multipart 流式提交的设计
上传头像最稳妥的形态是 multipart/form-data 表单上传,服务端按普通文件接收就行,不用解析 base64。这里放到原生层用 HttpURLConnection 实现,是为了避免大图 base64 编码后在 JS 和 Native 之间传输的额外开销,以及脚本层拼协议容易把超时、进度这些细节做丢。
public static String uploadFile(String url, String filePath, String fieldName, String token, int timeoutMs) throws Exception { File file = new File(filePath); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); String boundary = "Boundary-" + System.currentTimeMillis(); conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); if (token != null && !token.isEmpty()) { conn.setRequestProperty("Authorization", "Bearer " + token); } DataOutputStream out = new DataOutputStream(conn.getOutputStream()); out.writeBytes("--" + boundary + "\r\n"); out.writeBytes("Content-Disposition: form-data; name=\"" + fieldName + "\"; filename=\"" + file.getName() + "\"\r\n"); out.writeBytes("Content-Type: image/jpeg\r\n\r\n"); FileInputStream in = new FileInputStream(file); byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } in.close(); out.writeBytes("\r\n--" + boundary + "--\r\n"); out.flush(); out.close(); int code = conn.getResponseCode(); // 用流读取 response,不要用整个字符串承接大响应体 return readResponse(conn.getInputStream(), code); }这段上传实现有几个参数是业务上容易调错的。fieldName 对应服务端接口约定的字段名,常见的是 avatar,如果服务端用 file,这里必须跟着改,否则服务端永远收不到文件。boundary 是 multipart 的分隔字符串,客户端用什么值都行,服务端只认 Content-Type 里那个,但要保证正文里的分隔符和请求头一致。Authorization 头不是必须的,但很多业务接口已经换成 token 鉴权,所以预留了参数。
超时设置也单独提一下。上传头像的图片如果压缩不到位,一张原图可能有 10MB,弱网环境下 10 秒超时经常失败。源码包里把超时参数暴露给调用方了,实际业务建议对上传单独设置 30 秒以上的连接和读取超时,不要跟着全局网络配置走。另一个隐形问题是大文件上传时 response 的读取,千万别写死循环拼字符串,正文很多时这里会非常慢,readResponse 内部要按流式读取,并且对非 2xx 状态码读错误流。
4.3 下载与本地缓存:头像不闪回白的细节
上传成功拿到 URL 之后,第二个功能点是下载并缓存头像,保证下次进入页面不用重新拉网络图。实现思路是:以 URL 的 md5 作为缓存文件名,下载前先检查缓存文件是否存在,存在就直接返回本地路径给 Creator 显示;不存在才走网络下载,下载成功后写进应用私有目录。
// PhotoCache.ts 简化版 const DOWNLOAD_DIR = "avatar_cache"; export function loadAvatar(url: string, finished: (localPath: string) => void) { if (!url) { finished(""); return; } const cachePath = getCachePath(url); // avatar_cache/{md5}.jpg if (fs.existsSync(cachePath)) { finished(cachePath); return; } const request = new XMLHttpRequest(); request.responseType = "arraybuffer"; request.timeout = 15000; request.open("GET", url, true); request.onload = () => { if (request.status === 200) { fs.writeFileSync(cachePath, new Uint8Array(request.response)); finished(cachePath); } else { finished(url); // 下载失败时回退成远程地址 } }; request.onerror = () => finished(url); request.ontimeout = () => finished(url); request.send(); }这段代码在 Creator 脚本层做小文件缓存没问题,因为头像压缩后一般只有几十 KB 到几百 KB,arraybuffer 传输和写文件不会造成明显卡顿。它有三个细节值得学习。第一,缓存键用 URL 的 md5,而不是 URL 本身当文件名,因为 URL 里的斜杠冒号不能直接作文件名,md5 化之后天然规避了非法字符问题。第二,下载失败的回退逻辑是返回原始 URL,让图片组件继续以远程地址渲染,断网时不至于头像整个消失。第三,写入用fs.writeFileSync同步写,头像大小可控所以这里没有用异步接口,代码更直接。
需要特别说明的是缓存的存放路径。Creator 2.x 的 fs 模块默认工作在应用沙盒的 writablePath 下,这个目录在 Android 上对应/data/data/包名/files或类似位置,普通用户不可见,应用卸载后缓存随之消失,这是预期行为。如果产品要求头像缓存独立于卸载生命周期,那就得申请存储权限并把目录换到外部存储,但那样又会引入运行时权限和 Android 11 分区存储的限制,我的建议是头像这种非核心资产就放在沙盒里,别给自己找麻烦。
5. 避坑与排查:这套方案最容易翻车的 5 个位置
这一章说的 5 个坑,是相机相册能力接进不同 Creator 项目时容易反复遇到的,每一个都对应一段真实翻车经历。如果你已经跑通了源码包,最好把这几个位置当成上线前自检清单逐项过一遍;还没跑通的,遇到对应现象直接跳回来对照,比从头查日志快得多。
5.1 相机拍出来是横图,裁剪框却是竖的
现象:竖着手机拍照,选完图本应显示一张竖图,却变成横向躺着的;裁剪切出来方向更乱,头像脸是歪的。
原因:相机传感器坐标系和屏幕坐标系不一致,大部分机型为了保留画幅,传感器默认横向放置,竖拍时通过 EXIF 里的 Orientation 字段记录旋转信息。很多直接解码 Bitmap 的代码没有读这个字段,图片就被当横图处理了。
解决:在拿到原图文件后、裁剪前先读 EXIF Orientation,按旋转矩阵做映射,90 度旋转直接交换宽高,再调用 Bitmap.rotate 重写压缩。注意处理完要重新写文件,并且清除 EXIF 里的旋转标记,否则上传到服务端后,其它端下载这张图会看到旋转后还被 EXIF 标记转了一次,等于双重旋转。我在项目里就吃过这个双重旋转的亏,后来统一在落盘时就清掉 Orientation 字段,之后的裁剪、压缩、上传全都基于已经修正好的文件,后面的人接手也不用再猜图的方向。
5.2 相册返回 content:// 地址,转 file:// 报文件不存在
现象:从相册选择一张图片,日志打印的 uri 是content://media/external/images/media/123,用接口转成 file:// 后 File.exists 返回 false,解码 Bitmap 一直失败。
原因:Content Uri 不是真实文件路径,它表达的是媒体库里的一个条目,需要走 ContentResolver 打开输入流才能读到数据。直接做字符串替换转 file 路径是行不通的。
解决:统一走拷贝流程,用ContentResolver.openInputStream(uri)读入字节流,写入应用缓存文件,再拿缓存路径去裁剪。从 Android 10 开始,相册返回的还有可能是content://downloads/或文档提供器地址,处理方式相同,都是走输入流落盘。这里还有一个隐蔽点:拷贝之后要及时关流,很多项目里文件读一半流还开着,后续压缩、上传就会卡住或读到不完整的数据。
5.3 JSB 回调在部分机型上没有触发
现象:相册返回后脚本层的回调始终不执行,loading 一直转圈,或者直接崩在 Java 里报 findClass 失败。
原因:通常是两类问题。一类是 JSB 方法签名不匹配,脚本层调用的类路径、方法名、JNI 签名任何一个对不上,反射调用就静默失败;另一类是回调时机不对,Java 层在子线程直接调了脚本层接口,跨线程没有切回 UI 线程时消息丢失。
解决:排查第一类问题,先把 Java 方法体里加 Log,确认原生方法真的被调到了,再把参数逐个改成只含 int 的最简形态,排除签名错误;确认签名没问题后再加回调。排查第二类问题,所有 JSB 回调统一通过activity.runOnUiThread包一层,保证脚本层一定在主线程收到消息。强烈建议在回调外面包一层异常捕获,Java 回调脚本时如果脚本层抛异常,至少日志里有堆栈,而不是黑盒一样没有反馈。
5.4 下载完头像,重启应用后又变回默认图
现象:头像下载后界面正常显示,但杀进程重启后本地缓存文件找不到了,又回到默认头像。
原因:Creator 的 fs 操作默认基于jsb.fileUtils.getWritablePath(),不同平台的沙盒路径不同。如果脚本层写缓存时用了相对路径,或者把文件写到了临时目录,重启后系统清理临时目录就会丢;还有的项目把缓存文件写在了 assets 目录里,那不是可写区域,运行期写入会失败,但错误被吞掉了。
解决:缓存目录始终用可写路径拼接,把jsb.fileUtils.getWritablePath()和avatar_cache拼成完整路径;写入后立刻读一遍刚写的文件做回读校验,确认文件真实存在;不要用 assets 做运行期写入。我一般还会在下载方法里打一条日志记录最终的落盘绝对路径,真机上配合文件管理器或者日志工具一比对,就能确认文件写没写对地方。
5.5 相册里选了视频,上传阶段文件头不匹配
现象:用户在相册里翻到一个视频,当成图片选中后上传报错,或者服务端认为是图片但下载下来打不开。
原因:有些 ROM 的三方相册选择器对image/*过滤并不严格,尤其是文件管理器自己实现的选图界面,可能把视频也列进来并允许点选。
解决:在 onActivityResult 拿到 uri 后先查 MediaStore 的 MIME_TYPE 字段,或者直接打开输入流读文件头几个字节判断 JPEG/PNG 签名,两者不一致就回一个 STATUS_TYPE_ERROR。同时上传接口的 Content-Type 统一按图片写,真正要防的是源头。这个校验放在拷贝环节之前,可以提前把视频挡在外面,不至于把几十 MB 的视频落盘了才发现选错了。
6. 二次开发:把“选图-裁剪-上传-下载”改造成通用图片组件
6.1 抽象接口:先定协议再动代码
拿到源码包,第一件事不是改 Java 代码,而是把脚本层入口封装成稳定的 Promise 接口。我改造时会先抽象一个 ImagePicker 协议,把当前这套“来源 + 裁剪参数 + 回调”的用法收敛成几个方法。
export interface ImagePickOptions { sourceType: "CAMERA" | "ALBUM"; crop?: boolean; aspectX?: number; aspectY?: number; targetWidth?: number; } export interface ImagePickResult { status: number; // 0 成功 / 1 取消 / 2 失败 / 3 类型错误 localPath: string; // 成功时返回本地处理后的图片路径 message?: string; // 失败或取消时的描述 } export class AvatarPicker { static choose(options: ImagePickOptions): Promise<ImagePickResult> { return new Promise((resolve) => { PhotoHelper.chooseAndCrop(options.sourceType, { crop: options.crop, aspectX: options.aspectX || 1, aspectY: options.aspectY || 1, success: (localPath: string) => resolve({ status: 0, localPath }), fail: (code: number, msg: string) => resolve({ status: code, message: msg }), }); }); } }这样封装之后,业务层调用变成一行const res = await AvatarPicker.choose({ sourceType: "ALBUM", crop: true })。Promise 化的好处是选图、裁剪、上传、loading 状态能串成一条 async 链,不再嵌套回调。改造时把 options 里的 targetWidth 透传给原生层,让裁剪输出尺寸由业务决定,而不是写死在 Java 里。
6.2 扩展点与验证方法
二次开发真正有价值的扩展点有三个。第一,把上传地址和 token 从硬编码改成配置注入,PhotoUploader 构造函数接收 baseUrl 和鉴权头工厂函数,换环境不用重新打包。第二,增加压缩参数,原生层加一个 quality 选项,上传前用 Bitmap 重采样,头像控制在 128KB 以内,弱网上传快很多。第三,权限申请策略可配置化,把 CAMERA 和存储权限的申请顺序拆开,满足只做相册场景的产品。
验证这套改造是否成功,我习惯用一条固定流程:真机上分别跑相机拍照、相册选图、裁剪取消、上传断网重试、下载二次启动五个场景。其中裁剪取消最容易漏处理,Promise 必须保证最终能 resolve 而不是永远 pending。改造完成后最值得检查的一行代码是 JSB 签名是否跟着重构一起变了——我每次改动 Java 方法参数后都会强制回读一遍脚本层的 callStaticMethod 签名,从那以后这类桥接翻车少了一大半。希望帮到你。
本文还有配套的精品资源,点击获取