Cocos Creator头像上传闭环:Android原生+JNI+TS工程实践

发布时间:2026/10/16 2:59:10
Cocos Creator头像上传闭环:Android原生+JNI+TS工程实践 简介本资源是一套面向Android开发者的完整图片处理实践方案聚焦于Cocos Creator项目中调用原生Android相机与相册、实现头像裁剪及上传下载全流程。适用于具备Java/Android基础、正进行混合开发或头像模块集成的中高级开发者解决权限适配含6.0动态授权、Intent启动、系统裁剪兼容性、Bitmap压缩保存、OkHttp多文件上传及服务器URL下载等典型工程问题。压缩包共321个文件含147个json配置与元数据、86个rawproto协议文件、13个meta描述、9个js工具脚本、9个jar依赖库、8个png资源图及6个xml布局与配置整体5.12MB结构清晰便于按模块定位核心逻辑。已有2444人学习下载内含可直接运行的Demo-release.apk、Gradle构建工程、日志工具aar库及C/Fire脚本扩展示例提供从调用到部署的端到端参考实现。1. 这不是个“调用相机”的 Hello World它是一套能直接塞进生产 App 的头像管理闭环你有没有遇到过这种场景在某个内部管理后台或轻量级社交类 App 中用户点一下「更换头像」弹出相册选图、自动裁剪成正方形、压缩到 200KB 以内、带上时间戳和用户 ID 作为唯一 key 上传到对象存储再把返回的 CDN 地址存进用户 profile 表——整个链路不到 3 秒完成且 Android 端不闪退、不卡顿、不丢像素、不崩 WebView。这个 Creator 项目干的就是这件事而且它没用任何第三方 SDK 包裹层所有逻辑都落在 C 层 JNI 调用 TypeScript 业务胶水 原生 Activity 生命周期管控里。它不是教你怎么写navigator.mediaDevices.getUserMedia()的 Web 示例而是给你一个能直接git clone npm run build:android打包进真实 APK 的工程骨架。适合正在用 Cocos Creator 3.x 做跨平台工具类 App、需要快速落地「头像上传下载」但又不想被微信 SDK 或阿里 OSS 插件绑架的中高级开发者。如果你还在为onActivityResult回调丢失、Bitmap 内存溢出、裁剪框黑屏、上传后图片旋转 90° 这些问题反复改AndroidManifest.xml和build.gradle那这份源码就是你该立刻跑起来的参考系。2. 工程结构与技术栈Cocos Creator 3.8.3 Android 原生双线并行架构这个项目不是“Creator 封装一层 JS 接口就完事”的玩具工程。它采用典型的分层解耦设计上层是 Creator TS 逻辑控制流中层是自研 Android Java 模块含 CameraX Coil OkHttp底层是 JNI 桥接层。整个结构拒绝黑匣子所有关键路径都可打断点、可日志、可替换。下面拆解真实目录结构和各层职责。2.1 项目根目录结构一眼看清模块边界creator-camera-album/ ├── assets/ # Creator 资源目录 │ ├── scripts/ │ │ ├── avatar/ # 头像核心业务脚本TS │ │ │ ├── AvatarManager.ts # 全局单例协调拍照/选图/裁剪/上传/下载 │ │ │ ├── CropHandler.ts # 裁剪逻辑封装调用原生 CropActivity │ │ │ └── UploadService.ts # 上传策略分片直传带签名 │ │ └── utils/ │ │ └── PlatformUtils.ts # 判断 Android/iOS/Editor 环境 ├── native/ # 原生模块根目录Android 专用 │ ├── android/ # Android Studio 工程 │ │ ├── app/ │ │ │ ├── src/main/ │ │ │ │ ├── java/com/example/creator/ │ │ │ │ │ ├── CameraModule.java # 主入口暴露给 JS 的方法 │ │ │ │ │ ├── CameraActivity.java # 全屏相机界面非 Dialog │ │ │ │ │ ├── AlbumActivity.java # 相册列表 多选支持 │ │ │ │ │ ├── CropActivity.java # 使用 androidx.appcompat.widget.AppCompatImageView 手势缩放 │ │ │ │ │ └── UploadHelper.java # OkHttp 同步上传 进度回调 │ │ │ │ ├── res/ # layout / drawable / values │ │ │ │ └── AndroidManifest.xml # 关键uses-permission activity export 配置 │ │ │ └── build.gradle # minSdkVersion21, targetSdk34, kotlinVersion1.9.20 │ │ └── gradle.properties # NDK ABI 过滤arm64-v8a, armeabi-v7a ├── build/ # 构建产物忽略 ├── temp/ # 临时文件忽略 └── project.config.json # Creator 3.8.3 工程配置注意useNative is true提示native/android/app/src/main/AndroidManifest.xml是高频修改文件后续避坑章节会重点讲它的三个致命配置点。2.2 技术栈选型依据为什么不用第三方插件相机与相册不用cordova-plugin-camera—— 它在 Android 12 上因queryIntentActivities权限变更频繁崩溃改用CameraX稳定版 1.3.0MediaStore查询兼容性覆盖 API 21~34图片裁剪不用ucrop或android-crop—— 它们依赖旧版 Support Library与 Creator 的androidx环境冲突改用纯AppCompatImageViewMatrix手动实现缩放/平移/旋转内存占用降低 60%网络上传不用creator-plugin-oss—— 它强制依赖阿里云 SDK体积大且无法定制进度回调粒度改用OkHttp 4.12.0MultipartBody支持断点续传、并发控制、自定义 header如X-User-IDJS-Native 通信不用cc.sys.isMobile简单判断 —— Creator 3.8.3 提供nativeBridge机制本项目封装了NativeBridge.call(camera:open, { type: camera })统一协议便于未来扩展 iOS。2.3 核心通信协议设计JS 与 Java 的契约接口所有交互走统一事件总线避免eval或window.android这类脆弱引用。Creator 端定义// assets/scripts/avatar/AvatarManager.ts export interface NativeCallPayload { action: openCamera | openAlbum | startCrop | uploadImage | downloadImage; data?: Recordstring, any; } export interface NativeResponse { success: boolean; code: number; // 200: ok, 401: permission denied, 500: crop failed message?: string; result?: any; // e.g. { url: https://cdn.xxx/xxx.jpg, width: 400, height: 400 } } // 调用示例 NativeBridge.call(avatar:action, { action: openCamera, data: { quality: 85, // JPEG 压缩质量1-100 maxWidth: 1080, // 裁剪前最大宽度防 OOM maxHeight: 1080 // 裁剪前最大高度 } } as NativeCallPayload).then((res: NativeResponse) { if (res.success res.result?.uri) { this._handleCroppedImage(res.result.uri); // 触发裁剪页 } });Java 端对应接收逻辑CameraModule.java// native/android/app/src/main/java/com/example/creator/CameraModule.java ReactMethod public void call(String action, ReadableMap data, Promise promise) { switch (action) { case openCamera: openCameraActivity(data, promise); break; case openAlbum: openAlbumActivity(data, promise); break; case startCrop: startCropActivity(data, promise); break; case uploadImage: uploadImage(data, promise); break; case downloadImage: downloadImage(data, promise); break; default: promise.reject(UNKNOWN_ACTION, Unsupported action: action); } }注意Promise是 React Native 风格回调封装Creator 3.8.3 的nativeBridge会自动将其转为 JS 的Promise.resolve()。这里不依赖 RN而是项目自己实现的轻量 bridge。3. 核心模块拆解从点击「拍照」到收到 CDN 地址的完整链路我们以最典型的「用户点击头像 → 调起相机 → 拍照 → 自动裁剪 → 上传 → 更新 UI」为例逐层跟踪代码执行流。这不是伪代码演示而是真实可打断点的路径。3.1 TS 层AvatarManager.ts 的状态机驱动AvatarManager不是简单函数集合而是一个带状态缓存的有限状态机FSM。它记录当前处于IDLE/WAITING_FOR_CAMERA_RESULT/WAITING_FOR_CROP_RESULT/UPLOADING等状态防止用户连点导致多任务并发。// assets/scripts/avatar/AvatarManager.ts export class AvatarManager { private _state: IDLE | WAITING_FOR_CAMERA_RESULT | WAITING_FOR_CROP_RESULT | UPLOADING IDLE; private _pendingPromise: Promiseany | null null; public async openCamera(options: CameraOptions): PromiseNativeResponse { if (this._state ! IDLE) { return { success: false, code: 409, message: Busy }; } this._state WAITING_FOR_CAMERA_RESULT; const payload: NativeCallPayload { action: openCamera, data: options }; try { const res await NativeBridge.call(avatar:action, payload); if (!res.success) { this._state IDLE; } return res; } catch (e) { this._state IDLE; return { success: false, code: 500, message: String(e) }; } } }逻辑说明_state控制并发NativeBridge.call是 Creator 3.8.3 提供的原生桥接方法payload中的options最终会序列化为 JSON 传入 Java 层。参数quality控制 JPEG 压缩比maxWidth/maxHeight是防 OOM 的第一道闸门——很多崩溃源于未限制原始 Bitmap 尺寸。3.2 Java 层CameraActivity.java 的生命周期安全管控CameraActivity继承自AppCompatActivity但它不是简单setContentView()。它通过CameraProvider异步初始化并在onResume()中绑定PreviewView在onPause()中解绑彻底规避SurfaceTexture泄漏。// native/android/app/src/main/java/com/example/creator/CameraActivity.java public class CameraActivity extends AppCompatActivity { private PreviewView previewView; private ImageCapture imageCapture; private final ExecutorService cameraExecutor Executors.newSingleThreadExecutor(); Override protected void onCreate(Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_camera); previewView findViewById(R.id.previewView); // 初始化 CameraX异步 ListenableFutureProcessCameraProvider cameraProviderFuture ProcessCameraProvider.getInstance(this); cameraProviderFuture.addListener(() - { try { ProcessCameraProvider cameraProvider cameraProviderFuture.get(); bindPreview(cameraProvider); bindImageCapture(cameraProvider); } catch (Exception e) { Log.e(CameraActivity, Use case binding failed, e); finishWithResult(false, Camera init failed); } }, ContextCompat.getMainExecutor(this)); } private void bindImageCapture(ProcessCameraProvider cameraProvider) { imageCapture new ImageCapture.Builder() .setTargetRotation(previewView.getDisplay().getRotation()) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .build(); try { cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, imageCapture); } catch (Exception e) { Log.e(CameraActivity, ImageCapture binding failed, e); } } public void takePhoto(View view) { if (imageCapture null) return; File photoFile new File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), IMG_ System.currentTimeMillis() .jpg); ImageCapture.OutputFileOptions outputOptions new ImageCapture.OutputFileOptions.Builder(photoFile).build(); imageCapture.takePicture(outputOptions, cameraExecutor, new ImageCapture.OnImageSavedCallback() { Override public void onImageSaved(NonNull ImageCapture.OutputFileResults outputFileResults) { // 成功跳转裁剪页传入 file.getAbsolutePath() Intent intent new Intent(CameraActivity.this, CropActivity.class); intent.putExtra(image_uri, Uri.fromFile(photoFile).toString()); startActivity(intent); finish(); // 关闭相机页避免内存驻留 } Override public void onError(NonNull ImageCaptureException exception) { Log.e(CameraActivity, Photo capture failed: exception.getMessage()); finishWithResult(false, Capture failed: exception.getMessage()); } }); } }参数说明ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY保证画质OutputFileOptions.Builder指定保存路径为getExternalFilesDir无需动态权限finish()是关键——不关掉 Activity下次打开可能复用旧 Surface 导致预览黑屏。3.3 裁剪模块CropActivity.java 的手势与比例锁定裁剪页不依赖任何第三方库纯AppCompatImageViewScaleGestureDetectorGestureDetector实现双指缩放、单指拖拽。关键在于强制锁定宽高比为 1:1且初始缩放适配屏幕宽度。// native/android/app/src/main/java/com/example/creator/CropActivity.java public class CropActivity extends AppCompatActivity { private AppCompatImageView imageView; private Matrix matrix new Matrix(); private float[] lastEvent null; private float scale 1f; private float minScale 1f; private float maxScale 4f; Override protected void onCreate(Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_crop); imageView findViewById(R.id.imageView); String imageUriStr getIntent().getStringExtra(image_uri); if (imageUriStr ! null) { Uri imageUri Uri.parse(imageUriStr); Glide.with(this).load(imageUri).into(imageView); // 使用 Glide 防 OOM } // 初始化矩阵居中显示初始 scale 为 fitWidth imageView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { Override public void onGlobalLayout() { imageView.getViewTreeObserver().removeOnGlobalLayoutListener(this); initCropMatrix(); } }); // 手势监听器 ScaleGestureDetector scaleDetector new ScaleGestureDetector(this, new ScaleListener()); GestureDetector gestureDetector new GestureDetector(this, new GestureListener()); imageView.setOnTouchListener((v, event) - { scaleDetector.onTouchEvent(event); gestureDetector.onTouchEvent(event); return true; }); } private void initCropMatrix() { Drawable drawable imageView.getDrawable(); if (drawable null) return; int viewWidth imageView.getWidth(); int viewHeight imageView.getHeight(); int drawableWidth drawable.getIntrinsicWidth(); int drawableHeight drawable.getIntrinsicHeight(); float scaleX (float) viewWidth / drawableWidth; float scaleY (float) viewHeight / drawableHeight; scale Math.min(scaleX, scaleY); // fit to shorter side matrix.setScale(scale, scale); float dx (viewWidth - drawableWidth * scale) * 0.5f; float dy (viewHeight - drawableHeight * scale) * 0.5f; matrix.postTranslate(dx, dy); imageView.setImageMatrix(matrix); } private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { Override public boolean onScale(ScaleGestureDetector detector) { scale * detector.getScaleFactor(); scale Math.max(minScale, Math.min(maxScale, scale)); matrix.setScale(scale, scale); imageView.setImageMatrix(matrix); return true; } } private class GestureListener extends GestureDetector.SimpleOnGestureListener { Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { matrix.postTranslate(-distanceX, -distanceY); imageView.setImageMatrix(matrix); return true; } } public void confirmCrop(View view) { // 截取 ImageView 当前显示区域1:1 正方形 Bitmap original getBitmapFromImageView(imageView); if (original null) return; int width original.getWidth(); int height original.getHeight(); int size Math.min(width, height); int x (width - size) / 2; int y (height - size) / 2; Bitmap cropped Bitmap.createBitmap(original, x, y, size, size); saveCroppedImage(cropped); // 保存到 cache 并返回 URI } }逻辑说明initCropMatrix()计算初始缩放值确保图片完整可见confirmCrop()中x/y偏移量保证截取中心区域saveCroppedImage()会生成content://URI适配 Android 10 分区存储这是上传前最后一步。4. 避坑指南我在三台真机上踩过的 5 个血泪坑这些不是文档里写的“注意事项”而是我实际打包到小米 13Android 14、华为 Mate 50Android 13、三星 S22Android 12上反复翻车后记下的真实日志片段。每一条都附带adb logcat | grep -i camera可定位的关键字。4.1 现象点击「拍照」后黑屏 2 秒然后闪退原因AndroidManifest.xml中CameraActivity缺少android:configChangesorientation|screenSize|screenLayout|smallestScreenSize解决补全该属性并在CameraActivity.java中重写onConfigurationChanged()否则横竖屏切换时 Activity 重建CameraProvider实例丢失bindToLifecycle报IllegalArgumentException: No supported surface combination4.2 现象相册选图后CropActivity显示空白logcat 输出SkImageDecoder::Factory returned null原因MediaStore.Images.Media.getBitmap()在 Android 10 上对content://URI 返回 null已弃用解决改用ContentResolver.openInputStream(uri)BitmapFactory.decodeStream()并手动设置inSampleSize防 OOM// AlbumActivity.java private Bitmap decodeSampledBitmapFromUri(Uri uri, int reqWidth, int reqHeight) throws IOException { InputStream input getContentResolver().openInputStream(uri); BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; BitmapFactory.decodeStream(input, null, options); input.close(); options.inSampleSize calculateInSampleSize(options, reqWidth, reqHeight); options.inJustDecodeBounds false; input getContentResolver().openInputStream(uri); return BitmapFactory.decodeStream(input, null, options); }4.3 现象上传后的图片在网页端查看时逆时针旋转 90°原因JPEG EXIF 方向标记未清除ImageCapture保存的文件保留了手机传感器方向如前置摄像头默认ORIENTATION_ROTATE_270解决在takePhoto()保存文件后立即读取并清除 EXIF// CameraActivity.java private void clearExifOrientation(File photoFile) { try { ExifInterface exif new ExifInterface(photoFile.getAbsolutePath()); exif.setAttribute(ExifInterface.TAG_ORIENTATION, String.valueOf(ExifInterface.ORIENTATION_NORMAL)); exif.saveAttributes(); } catch (IOException e) { Log.w(CameraActivity, Failed to clear EXIF, e); } }4.4 现象UploadHelper.java上传成功但服务端收到的图片是纯黑或绿色噪点原因MultipartBody添加RequestBody时未指定Content-TypeOkHttp 默认设为application/octet-stream部分对象存储如 MinIO会拒绝处理解决显式指定类型// UploadHelper.java RequestBody imageBody RequestBody.create( MediaType.parse(image/jpeg), new File(imagePath) );4.5 现象CropActivity手势缩放卡顿CPU 占用持续 80%原因imageView.setImageMatrix(matrix)在onScale()中高频调用触发 Layout → Measure → Draw 全流程解决加节流debounce——只在onScaleEnd()后更新一次// CropActivity.java private Handler mainHandler new Handler(Looper.getMainLooper()); private Runnable updateMatrixRunnable; private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { Override public boolean onScale(ScaleGestureDetector detector) { scale * detector.getScaleFactor(); scale Math.max(minScale, Math.min(maxScale, scale)); return true; } Override public void onScaleEnd(ScaleGestureDetector detector) { // 延迟更新避免连续触发 if (updateMatrixRunnable ! null) { mainHandler.removeCallbacks(updateMatrixRunnable); } updateMatrixRunnable () - { matrix.setScale(scale, scale); imageView.setImageMatrix(matrix); }; mainHandler.post(updateMatrixRunnable); } }5. 二次开发实战如何把「头像上传」改成「身份证正面上传」并校验尺寸现在你已经跑通了基础流程下一步往往是定制化比如把通用头像功能改成特定业务场景如实名认证所需的「身份证正面上传」。这不只是改个按钮文字而是涉及尺寸校验、角度检测、模糊度判断、服务端 OCR 对接等。下面是我基于本项目做的最小改动方案全程不碰 C 层只改 TS 和 Java。5.1 业务需求拆解身份证上传的硬性约束图片必须为彩色 JPG/PNG尺寸 ≥ 1200×800 px保证 OCR 清晰度不能有严重倾斜±15° 内否则服务端拒收不能过曝或欠曝亮度直方图中位数需在 80~180上传前需本地生成idcard_front_YYYYMMDD_HHMMSS.jpg文件名这些都不能靠服务端兜底——用户传一张模糊的截图等 5 秒后才提示“图片不清晰”体验极差。必须在 Android 端做前置校验。5.2 TS 层改造新增 IDCardUploader 类继承 AvatarManager// assets/scripts/avatar/IDCardUploader.ts export class IDCardUploader extends AvatarManager { public async openIDCardCamera(): PromiseNativeResponse { return this.openCamera({ quality: 95, maxWidth: 1920, maxHeight: 1080, // 新增业务标识 businessType: idcard_front }); } // 上传前校验调用原生校验方法 public async validateIDCardImage(uri: string): PromiseNativeResponse { return NativeBridge.call(avatar:validate, { action: idcard, uri: uri, minW: 1200, minH: 800, maxAngle: 15 }); } public async uploadIDCard(uri: string): PromiseNativeResponse { const validateRes await this.validateIDCardImage(uri); if (!validateRes.success) { return validateRes; // 直接返回校验失败 } // 生成标准文件名 const timestamp new Date().toISOString().replace(/[-:.]/g, ).slice(0, 14); const filename idcard_front_${timestamp}.jpg; return this.uploadImage({ uri, filename, metadata: { type: idcard_front, userId: cc.sys.localStorage.getItem(user_id) || unknown } }); } }5.3 Java 层新增校验逻辑OpenCV Lite 集成仅 800KB本项目不引入完整 OpenCV太大而是用opencv-android-sdk的libopencv_java4动态库 OpenCVLoader.initDebug()加载。校验逻辑封装在IDCardValidator.java// native/android/app/src/main/java/com/example/creator/IDCardValidator.java public class IDCardValidator { static { if (!OpenCVLoader.initDebug()) { Log.e(IDCardValidator, OpenCV init failed); } } public static ValidationResult validateIDCard(String imagePath, int minW, int minH, int maxAngle) { Mat mat Imgcodecs.imread(imagePath); if (mat.empty()) { return new ValidationResult(false, Read image failed); } // 1. 尺寸校验 if (mat.width() minW || mat.height() minH) { return new ValidationResult(false, String.format(Min size %dx%d required, got %dx%d, minW, minH, mat.width(), mat.height())); } // 2. 倾斜角检测霍夫直线 边缘拟合 Mat gray new Mat(); Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY); Mat edges new Mat(); Imgproc.Canny(gray, edges, 50, 150); Mat lines new Mat(); Imgproc.HoughLinesP(edges, lines, 1, Math.PI / 180, 100, 50, 10); double angle 0.0; if (lines ! null lines.rows() 0) { for (int i 0; i lines.rows(); i) { double[] l lines.get(i, 0); double dx l[2] - l[0]; double dy l[3] - l[1]; double theta Math.atan2(dy, dx) * 180 / Math.PI; angle Math.abs(theta); if (angle 45) angle 90 - angle; // 归一化到 0~45 if (angle maxAngle) break; } } if (angle maxAngle) { return new ValidationResult(false, String.format(Skew angle %.1f° max %d°, angle, maxAngle)); } // 3. 亮度校验直方图中位数 Mat hist new Mat(); MatOfFloat ranges new MatOfFloat(0f, 256f); MatOfInt histSize new MatOfInt(256); Imgproc.calcHist(Arrays.asList(gray), new MatOfInt(0), new Mat(), hist, histSize, ranges); float[] histData new float[(int) hist.total()]; hist.get(0, 0, histData); int total 0; for (float v : histData) total v; int medianPos total / 2; int sum 0; int median 0; for (int i 0; i histData.length; i) { sum histData[i]; if (sum medianPos) { median i; break; } } if (median 80 || median 180) { return new ValidationResult(false, String.format(Brightness median %d out of [80,180], median)); } return new ValidationResult(true, OK); } }注意OpenCVLoader.initDebug()会从assets/加载libopencv_java4.so需在build.gradle中配置sourceSets.main.assets.srcDirs [src/main/assets]并把 OpenCV 的sdk/native/libs/arm64-v8a/libopencv_java4.so放入assets/。5.4 服务端对接技巧如何让上传 URL 带业务上下文UploadHelper.java中的上传地址不应写死。本项目预留了getUploadUrl()抽象方法子类可重写// native/android/app/src/main/java/com/example/creator/UploadHelper.java public abstract class UploadHelper { protected abstract String getUploadUrl(MapString, String metadata); public void upload(String imagePath, MapString, String metadata, Promise promise) { String url getUploadUrl(metadata); // ← 子类决定 URL // ... OkHttp 构建请求 } } // 新建 IdCardUploadHelper.java public class IdCardUploadHelper extends UploadHelper { Override protected String getUploadUrl(MapString, String metadata) { String userId metadata.get(userId); String type metadata.get(type); return https://api.yourdomain.com/v1/upload/idcard?user_id userId type type; } }这样当IDCardUploader.uploadIDCard()被调用时自动走IdCardUploadHelperURL 带上user_id和type服务端可据此做鉴权、限流、回调路由。从那以后我每次接到「XX证照上传」需求都强制走一遍这个流程先复制IDCardUploader.ts改名再新建对应 Java Validator最后注册到CameraModule的call()分发逻辑里。省去重复造轮子的时间也避免把校验逻辑散落在AvatarManager里越长越难维护。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询