首页 / 资讯中心 / 文章详情

Creator调Android相机相册:JSB桥接、裁剪与上传下载完整实现

Creator调Android相机相册:JSB桥接、裁剪与上传下载完整实现 ★ FEATURED ARTICLE
简介面向需要在安卓端实现头像选择、裁剪与上传下载功能的开发人员这份压缩包提供了一套基于 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 typefile acceptimage/*就能打开系统文件选择器图片直接以 base64 或者 Blob 形式拿到手。但同样的代码打包成 Android 原生应用后web 那套 DOM API 在 JSB 环境里并不存在打开相册、读取相册图片、写回临时文件这些操作全都够不着。社区里常见的做法是在原生层注册一个可被反射调用的静态类把 Java 方法暴露给 TypeScript这就是整套方案的核心思路原生层负责所有系统能力脚本层只负责把参数传进去、把结果接回来。这个分工还带来一个容易被忽略的好处图片的读取、旋转、压缩、上传、下载都在原生层用文件流处理大图不会整张读进 JS 内存。用 base64 在 JS 和 Java 之间传一张 3000x4000 的照片光字符串就四五 MBGC 压力相当大而传文件路径只需要几百字节。所以这套代码里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 到几百 KBarraybuffer 传输和写文件不会造成明显卡顿。它有三个细节值得学习。第一缓存键用 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): PromiseImagePickResult { 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 签名从那以后这类桥接翻车少了一大半。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站