Files
ios/Xuxucheck/docs/code-review.md
T
admin d6aeede2bc 导入 XuXuCheck 工程(iOS 干化学尿液试纸分析 App)
从桌面工程复制而来,作为本仓库的 Xuxucheck/ 子目录。

为控制仓库体积做了两处删减:
- 移除 Frameworks/opencv2.xcframework 下的两个 visionOS 切片
  (xros-arm64、xros-arm64-simulator,合计约 106 MB),并同步删掉该
  xcframework 内 Info.plist 里对应的两条 AvailableLibraries 记录,
  只保留 ios-arm64 与 ios-arm64_x86_64-simulator。
- 移除全部 .DS_Store 与 xcuserdata/。

已用 xcodebuild 从本目录完整编译验证:BUILD SUCCEEDED,
ProcessXCFramework 以 --platform ios 成功选中框架,产物含 3699 个 cv:: 符号
(OpenCV 确实链入),嵌入 framework 3 个,与移除无用 pod 之后的状态一致。

仓库约 206 MB,其中 OpenCV 203 MB、其余源码与依赖约 3 MB。
2026-09-23 14:17:31 +08:00

46 KiB

代码审查报告

审查对象:/Users/henseycai/Desktop/XuXuCheck(iOS / SwiftUI,c. 10,200 行 Swift + ObjC++/C++ 源码) 审查日期:2026-09-23 审查范围:App 自有代码(Swift)+ 工程配置;第三方 vendored 代码与 opencv/ C++ 管线单独说明

方法:逐文件通读 App 自有 Swift,配合全仓 grep 统计与逐处行号定位;每条结论都给出文件:行号,可直接跳转核对。 凡是我无法在本机验证行为的部分(需要真机、需要服务端配合),都明确标注为「待确认」,不与已确认项混在一起。


一、结论摘要

级别 数量 说明
🔴 高 8 会导致数据错误、崩溃或功能静默失效,建议优先修
🟠 中 14 特定路径下的崩溃 / 状态不同步 / 体验缺陷
🟡 低 · 整洁度 10 类 死代码、本地化缺失、语义错误写法;不修也能跑,但会持续误导后来者

审查覆盖三块:App 自有 Swift、C++/OpenCV 管线(opencv/,20 个文件)、 vendored 第三方代码(DynamicColor/、qrcode/、toast/、utils/CachedAsyncImage.swift), 结论统一按下面的分级编号(H/M/L)。

架构本身是清楚的(Observation 框架 + 环境注入、单例 API 门面、WebView 双向桥), OpenCV 管线的几何与颜色链路也是自洽的,没有发现系统性的设计错误。问题集中在四类:

  1. 调试期的临时代码没有清理(打印、裸文案、注释掉的替代实现);
  2. 状态在多处各写一份且没有统一入口(登录态、导航条、首页链接、全局结果对象);
  3. 对远端/异步输入不做防护的强制转换(桥消息、OpenCV 返回值、扫码回调);
  4. 采样环节普遍缺边界校验(C++ 采样矩形、Swift 侧的下标与强转)。

其中 H6 / H7 是本报告优先级最高的两条 —— 它们是同一个 bug 的两半, 触发条件平凡(试纸放得靠边一点),后果是进程被杀或测量值错位。


二、🔴 高优先级

H1. getVCode 成功路径从不回调,验证码发送失败时用户看不到任何提示

  • XuXuCheck/api/APIService.swift:76-102 —— completion 只在 response.error != nil 的分支里被调用 (第 96 行)。成功返回(HTTP 200)时整个函数直接结束,既不返回 model 也不返回 error。
  • XuXuCheck/Login/ViewModels/LoginViewModel.swift:84-98 —— 调用方的 completion 体里也只有错误分支: 成功时本来就什么都不做。
  • XuXuCheck/Login/Views/LoginBuilder.swift:69-107 —— 「获取验证码」按钮点击后本地启动 60 秒倒计时, 不等任何回调。

三者叠加的后果:

  1. 服务端返回业务错误(model.code > 0,例如手机号已注册、发送过于频繁)时,在 responseObject 映射 成功的情况下 response.error 为 nil → 既不回调也不报错 → 界面无任何提示,而按钮已经进入 60 秒倒计时, 用户只能干等。CLAUDE.md 里「model.code > 0 是业务错误」的约定在这个接口上完全没有被执行。
  2. 全仓 6 个 API 方法中,只有这一个漏掉了成功回调 —— 是遗漏,不是设计。

建议:与其他 5 个方法保持一致,在 responseObject 的闭包里解析 model.code > 0 并回调 completion(model, err),成功时 completion(model, nil);LoginViewModel 的错误分支已有 UI 通路,接上即可。

H2. 注册/登录上报的 system_version 填的是设备名称

  • XuXuCheck/api/Router.swift:61,63,65:
    "system_version": brandName, "device_model": systemModel, "device_brand": brandName
    
  • XuXuCheck/utils/Globe.swift:65:let brandName = UIDevice.current.name —— 这是用户自己起的设备名 (如「张三的 iPhone」),不是系统版本号;system_version 的正确取值是 UIDevice.current.systemVersion(如 18.4)。
  • 同时 device_brand 也是 brandName,而它应该是厂商(Apple)。
  • Globe.swift:63,66 定义了 systemName / systemVersion 两个全局量 —— 全仓零使用,只有定义本身 (见 D 类死代码),看起来就是原本要用在这里、后来接错了线。

影响:服务端收集的设备信息是错的,且设备名属于用户可识别的个人信息。一旦服务端按 system_version 做兼容性判断或统计分桶,结论全是错的。

建议:改为 "system_version": systemVersion, "device_brand": "Apple";顺手删掉没人用的 systemName。

H3. 网络层把请求头与请求参数整个打进日志,包含明文密码与 Bearer token

  • XuXuCheck/api/Router.swift:118-119:
    print(request.allHTTPHeaderFields ?? "")
    print(params ?? "")
    
    这是 URLRequestConvertible 的统一出口,所有请求都过;登录/注册的 params 里有 password、 confirm_password,登录后的每个请求头里都有 Authorization: Bearer <usertoken>。
  • 另外 XuXuCheck/api/APIService.swift 有 6 处把完整响应体 print 出来 (String(decoding: data, as: UTF8.self)),含用户资料、检测记录等业务数据。
  • XuXuCheck/Components/WebViewRepresentable.swift:94-110 还会打印桥消息的原始 JSON。

影响:这些输出会进入设备系统日志(真机接 Xcode / Console.app、以及用户主动抓取的 sysdiagnose 里都能看到)。生产包带着密码和 token 打日志属于隐私缺陷;Log 量本身也拖慢高频请求。

建议:删掉这 8 处 print(整仓共 53 处 print + 25 处 NSLog/debugPrint, 建议统一收敛到一个 #if DEBUG 的 XuXuLog 包装里)。

H4. 首页四个入口的 URL 在首次访问时就被冻结,登录 / 切成员后不更新

  • XuXuCheck/Model/Home.swift:31-36 —— var homeFuncblocks 是文件级全局变量,数组元素在初始化时 调用 GetGeneralRequestParams() 把 query 拼进 link(含 usertoken、member_id)。 Swift 全局 var 是懒加载且只求值一次。
  • XuXuCheck/utils/Globe.swift 的 GetGeneralRequestParams() 每次调用都读当下的 Storage.shared.usertoken / member_id,但这里只被调用了一次。
  • XuXuCheck/Home/Views/HomeView.swift:175(渲染卡片)、:46-47(点击后用 homeFuncblocks[idx].link 决定去哪个 URL)都在消费这份冻结的值。

后果:App 启动时未登录 → 四个入口的 usertoken 为空,登录成功后依然是空的; 切换成员后卡片链接里的 member_id 也还是旧的。要等 App 重启才恢复正常。 即「登录 → 回首页 → 点『Records』」这条最常规的路径上,带过去的就是过期身份。

建议:把 homeFuncblocks 改成函数 func homeFuncblocks() -> [BlockItem], 或在 HomeView 里用 .task/.onChange(of: loginStatus.isLogin) 时重建; 标题里的 localizeString(...) 也适合在此时一并求值(见 L1)。

注:服务端拿到过期 token 后是跳登录还是报错,决定这条的用户可感知程度 —— 建议真机确认一次, 但无论哪种,当前行为都不是预期行为。

H5. 【真机】扫码成功的回调不在主线程,回调里却改 SwiftUI 状态并调用 WKWebView API

  • 调用方:XuXuCheck/Components/XuXuWebView.swift:153-163、XuXuCheck/Mine/Views/MineView.swift:156-161 —— 两处都在 .success 回调里写 @State(scannedCode、iScanQrPresented), webview 那处还会调 webViewModel.callJsBarcode(named:) → XuXuCheck/Model/WebViewModel.swift:85 webView?.evaluateJavaScript(...)。

  • 回调来源:XuXuCheck/qrcode/ScannerViewController.swift。这条链有点绕,逐步看:

    1. metadataOutput(_:didOutput:from:)(:440)运行在主队列 (:272 setMetadataObjectsDelegate(self, queue: DispatchQueue.main));
    2. 它并不直接上报结果,而是先组装 handler 闭包(:453),found(result) 的 5 处调用 (:459,465,472,477,482)全都在这个闭包内部;
    3. 因为 requiresPhotoOutput 默认为 true(qrcode/CodeScanner.swift:100,本 App 两个调用点都没传), :487-490 走的是 photoOutput.capturePhoto(..., delegate: self),handler?(nil) 那条路不可达;
    4. 于是 handler 唯一的实际触发点是 :573 —— 在 AVCapturePhotoCaptureDelegate 的 photoOutput(_:didFinishProcessingPhoto:error:) 里;
    5. 而 AVCapturePhotoOutput 的 delegate 回调不在主队列(SDK 头文件原话: "AVCapturePhotoOutput invokes the AVCapturePhotoCaptureDelegate callbacks on a common dispatch queue — not necessarily the main queue");
    6. :417-425 的 found() 直接 parentView.completion(.success(result)),没有回主队列。
  • 本文件自己暴露了这个不一致:紧邻的 didFail(:427-431)是显式包了 DispatchQueue.main.async 的, found 没包。写这段代码的人知道回调不在主线程,只是漏了这一条路径。

后果:真机上扫到任意一个二维码都会命中 —— 非主线程写 SwiftUI @State(UI 不更新或崩溃), 以及非主线程调 evaluateJavaScript(WebKit 主线程检查;轻则 JS 静默不执行,表现为扫码看起来成功了 但网页收不到条码)。模拟器路径(:97,touchesBegan)恰好在主线程,所以这个 bug 在模拟器上测不出来。

建议:在 found() 里把上报包进 DispatchQueue.main.async(与 didFail 对齐); 或在两个调用点各自 DispatchQueue.main.async。前者一处改完。

H6. 【C++ 管线】采样矩形的原点可以是负数,越界抛 cv::Exception → 进程崩溃

  • 位置:XuXuCheck/opencv/OpencvWrapper.mm:155
    cv::Rect roi(pt.x - 5, pt.y - 5, 10, 10);
    Mat roiRgbMat(meanMat, roi);          // 156
    
    pt 来自 C++ 回传的 positions(各试剂垫中心)。
  • C++ 侧对垫位坐标不做任何边界保证。这条最好用同一个函数里的对照来说明: ImgLocateDryChemistry.cpp:1015-1021 对白平衡块做了完整校验,用的还是同一套 -5 边距的写法:
    cv::Rect wbRc((int)wbPt.x - 5, (int)wbPt.y - 5, 10, 10);
    if (wbRc.x <= 0 || (wbRc.x + wbRc.width) >= (roi.width - 1)
        || wbRc.y <= 0 || (wbRc.y + wbRc.height) >= (roi.height - 1))
        return (recode = 4);
    
    而垫位点(roiPositions,在 :991 / :1000 推入)产出后从无同类校验。 也就是说:管线里唯一被校验的点,恰好是唯一不在 Swift 侧被采样成 10×10 矩形的那个点。
  • 触发条件:pt.x < 5 或 pt.y < 5 或 pt.x + 5 > meanMat.cols(试纸贴到图像边缘时)。 定位逻辑本身允许垫位靠近边缘 —— 例如 analyseDefinition 只要求小定位块中心离 ROI 边缘 > 0(ImgLocateDryChemistry.cpp:623-626),而 ROI 是整帧的上 1/3(OpencvWrapper.mm:90), 贴边的定位块就会外推出 y ≈ 4 的垫位。

后果:Mat(const Mat&, const Rect&) 对 roi.x >= 0、roi.x + roi.width <= cols 直接 CV_Error。 异常从 Objective-C++ 帧抛出后穿过 Swift 帧——Swift 无法 catch C++ 异常——最终 std::terminate,进程被杀。 整条管线没有一处 try/catch,Swift 侧也没有任何兜底。这不是「某台设备偶发」,而是「试纸放得靠边就崩」。

建议:在采样前把矩形夹到合法范围内并跳过不合法的点,例如

if (pt.x < 5 || pt.y < 5 || pt.x + 5 > meanMat.cols || pt.y + 5 > meanMat.rows) continue;

另外建议给 +analysisImage: 整体包一层 try/catch(cv::Exception&),把「崩溃」降级为「本帧无效」—— 相机场景里丢掉一帧的代价远小于杀进程。

H7. 【C++ 管线】totalPt 只用来计数、却拿去当下标上界 → 有效性过滤完全失效 + 垫位整体错位

  • 位置:XuXuCheck/opencv/OpencvWrapper.mm:140-156,两段循环紧挨着:
    for (int i = 0; i < ITEM_SIZE; ++i) {
        if (i >= positions.size()) break;
        xs[i] = positions[i].x;  ys[i] = positions[i].y;
        if (xs[i] > 0 && ys[i] > 0) { totalPt += 1; }     // 只是数了个数
    }
    for (int i = 0; i < totalPt - 1; ++i) {               // 却当成"前 totalPt-1 个"
        cv::Point pt(xs[i], ys[i]);
        cv::Rect roi(pt.x - 5, pt.y - 5, 10, 10);
    
    第二个循环恒从下标 0 开始取,过滤结果从未参与取值。 作者会写 xs[i] > 0 && ys[i] > 0 这个判断,说明实践中见过非正的坐标 —— 但这个判断对实际采样没有任何作用。
  • 阈值也不够:它只要求 > 0,而安全下限是 5(矩形会向两侧各外扩 5px), 即 x ∈ [1,4] 的点会被判"有效"却照样产出负原点 → 走 H6 的崩溃。

后果(两个,都实际发生):

  1. 非法点照样进入 cv::Rect,即上面 H6 的崩溃;
  2. totalPt - 1 的本意是排除末尾追加的白平衡点(ImgLocateDryChemistry.cpp:1034 roiPositions.push_back(wbPt) 确认了这个约定:positions = n 个垫位 + 1 个白平衡点)。 一旦任意一个垫位被判无效,totalPt 就少 1,循环上界随之少 1 → 最后一个试剂垫的测量被丢弃, 而非法点仍占据一个结果行。结果是这一帧的 16 行数据里,行的含义整体错位。

建议:把两段循环合并成一遍,先筛出合法点(带 5px 边距),再按合法点数采样; 白平衡点用「排除末尾一个」之外的显式方式标出(例如让 C++ 侧回传垫位数),不要再靠 -1 这种位置约定。

H8. 【C++ 管线】ImgResult 是进程级全局对象,每帧复用,未写入的行残留上一次分析的数据

  • 位置:XuXuCheck/opencv/OpencvWrapper.mm:80
    @implementation OpenCVWrapper
    ImageLocation imgLoc;
    ...
    ImgResult* result = [ImgResult new];     // 80:文件作用域,只构造一次
    
    这行在 @implementation 里、函数之外,是静态变量,不是每帧新建的局部对象。 +analysisImage:(:83-179)每帧返回的都是同一个实例,只覆写它自己写过的那部分: 采样循环只写 result.paperAnalResults[i],而 i 的上界是 totalPt - 1(:153)。
  • 于是没被写到的行保持原值 —— 而那可能是本次分析的前一帧、也可能是上一次扫描留下的 (全局对象在进程生命周期内一直活着)。ImgResult.m:13-38 的 init 只在第一次构造时跑过一次。

后果:CameraView.swift:52 把 result.paperAnalResults as! [[Double]] 直接 append 进 16 帧累计, 最终由 Router.interface_result_addrecord 上传。当相邻两帧识别出的试纸型号不同(垫位数不同)时, 多出来的行是上一条试纸(甚至上一个用户上一次检测)的测量值,会被当作本次结果上传。 不崩溃,但结论是错的 —— 对一个判读类 App,这比崩溃更麻烦。

建议:把 result 改成每次调用新建(ImgResult* result = [ImgResult new]; 挪进函数内), 或在每次分析的入口把 paperAnalResults 全部重置为 0。 顺带:paperWbValue[3](有效标志)是每帧都写的,所以 CameraView 的门控判断没问题 —— 不能靠这个标志判断「数据行是否是本帧的」。


三、🟠 中优先级

M1. login 命令的登录态只清了一半,且「跳登录页」的动作从来没有发生

  • XuXuCheck/Components/WebViewRepresentable.swift:159-172,网页上报 login 时:清空 Storage 各字段 + parent.loginStatus.routeToLoginView = true。
  • 但 logout 分支(:145-158)清理完还会执行 parent.loginStatus.isLogin = false(:156), login 分支没有这一句。
  • 更关键的是:routeToLoginView 这个标志全仓没有任何地方读取 (定义在 XuXuCheck/Model/LoginStatus.swift:13,唯一的另一处就是这里的赋值)。

后果:网页要求跳登录时,Storage 已登出但 loginStatus.isLogin 仍为 true。 若用户此刻正停在「成员 / 记录 / 我的」tab 上,ContentView:23,30 的分支仍判定为已登录, 界面继续显示登录后的内容,而任何后续请求都会 401 —— 直到 App 重建才会自愈 (XuXuCheckApp.swift:29 在启动时才做一次两者同步)。

建议:补 parent.loginStatus.isLogin = false;routeToLoginView 要么让某个视图真正消费它 (监听后切到 LoginView),要么删掉这个字段,不要留一个写着「tell us we need to route to LoginView()」 的注释却没人接。

M2. 桥消息用 as! String 强转远端内容,网页一改就崩

  • XuXuCheck/Components/WebViewRepresentable.swift:95:
    let messageFromWebPage = message.body as! String
    
    下一页行还有 messageFromWebPage.data(using: .utf8)!。

message.body 的类型完全由网页决定。CLAUDE.md 记的契约是「网页 post 一个 JSON 字符串」, 但只要服务端某次发布把 postMessage(JSON.stringify(x)) 写成 postMessage(x), body 就变成 NSDictionary,这行立即 EXC_BAD_INSTRUCTION 崩溃 —— 而 App 侧无法通过发版以外的方式止损。

建议:改成 guard let messageFromWebPage = message.body as? String else { return }, 再补一句日志。同类问题见 M8。

M3. selectcamera 命令在无相机环境下会直接抛异常崩溃

  • XuXuCheck/utils/ImagePicker.swift:20-25,makeUIViewController 里直接 viewController.sourceType = sourceType,没有检查 UIImagePickerController.isSourceTypeAvailable(...)。
  • 触发路径:XuXuCheck/Components/XuXuWebView.swift:100-103 的 case "selectcamera" → imageViewModel.takePhoto() → sourceType = .camera → 弹出 picker。
  • 当相机不可用时(模拟器;或真机上被 MDM / 屏幕使用时间限制关闭了相机), UIImagePickerController 会在赋值 sourceType 时抛 NSInvalidArgumentException —— 这是崩溃,不是空画面。

建议:guard UIImagePickerController.isSourceTypeAvailable(sourceType) else { return } (或 takePhoto() 里先判断,不满足就回一条 JS 给网页),同时给「相册」分支留出同样的保护。

M4. 首页点过任意一张卡片之后,状态栏再也不显示

  • XuXuCheck/Home/Views/HomeView.swift:183 在卡片点击动作里 showStatusBar = false, 唯一的消费者是同一文件的 :44 .statusBar(hidden: !showStatusBar), 而 showStatusBar(:14)没有任何地方再被置回 true。

HomeView 是 Home tab 的根视图,不会因为跳转而销毁,所以这个「隐藏」在本次 App 生命周期内是永久的: 用户点一次卡片(哪怕立刻返回)之后,Home 页的状态栏就一直消失。

建议:在卡片闭合/返回时恢复(showMods = false 的那条路径里补 showStatusBar = true), 或直接删掉这组状态 —— 它看起来是早期全屏动画的残留。

M5. CIContext 每帧新建一次

  • XuXuCheck/Camera/CameraManager.swift:142,CIImage.cgImage 计算属性里 let ciContext = CIContext()。

CIContext 的构造成本很高(内部要建 Metal/OpenGL 管线与缓存),而它被设计为创建一次、长期复用。 在相机预览这条路径上按帧率(约 30fps)反复创建,是明确的性能与发热问题,也让首帧变慢。

建议:提为 CameraManager 的属性(private let ciContext = CIContext()),或直接用 CIContext(options:) 复用。

M6. AVCaptureVideoDataOutput 的 delegate 强引用 self(需确认停止路径)

  • XuXuCheck/Camera/CameraManager.swift:92 videoOutput.setSampleBufferDelegate(self, queue: sessionQueue) —— AVCaptureVideoDataOutput 强引用其 delegate,self 又被 session 持有,构成引用环。
  • 文件里确实有 stopSession()(:73)且在 :64 被调用,所以只要退出相机页就会拆环,目前不构成泄漏; 但如果将来出现「扫描页未正常消失」的路径(例如被 fullScreenCover 异常关闭),session 与 CameraManager 会一起活着。

建议:保留现状即可,但建议在 stopSession() 里显式 videoOutput.setSampleBufferDelegate(nil, queue: nil) 把环拆干净,不依赖调用时序。

M7. URL(...)! 与无边界检查的下标访问

  • XuXuCheck/api/Router.swift:26 —— URL(string: ...)!。Base URL 目前是常量,风险低; 但同一文件里 desc[0] / desc[1] / desc[2] 直接索引 [Int],数组来自调用方传入的 paperTypeDesc,越界即崩溃。CLAUDE.md 已经点明这条数据链的形状由 C++ 侧决定, 一旦 OpenCV 那边改了维度,这里不是报错而是 crash。

建议:URL(...) 若确实不可失败可保留但加注释;下标改为 desc.indices.contains(i) 保护, 或改成 guard desc.count >= 3。

M8. 强制转换清点(共 5 处活的 as!)

位置 表达式 风险
Components/WebViewRepresentable.swift:95 message.body as! String 远端内容,见 M2
Camera/CameraView.swift:43 result.paperWbValue as! [Double] 与 C++ 侧维度强耦合
Camera/CameraView.swift:52 result.paperAnalResults as! [[Double]] 同上
Camera/CameraView.swift:63 result.paperTypeDesc as! [Int] 同上

这三处是 ImgResult 的 NSMutableArray → Swift 类型的强制桥接,CLAUDE.md 明确记录了「维度一变就崩, 要同步改 ImgResult.m 的默认值」。它们是已知的、可接受的设计,但集中放在处理循环的 body 里 (见下一条),任何一次不匹配都是必崩而非降级。建议至少包一层 guard let ... as? ... else { return }, 让畸形帧被丢弃而不是杀死 App。

M9. 处理循环写在 body 里,且在 body 中改共享状态

  • XuXuCheck/Camera/CameraView.swift:41-70 —— body 每次求值都同步调用 OpenCVWrapper.analysisImage(uiImage),并在其中 cameraViewModel.array3D.append(...)(:52)、 累积到 16 帧后调用 cameraViewModel.addRecord(...)(:63)。

CLAUDE.md 已把「body 就是处理循环」记为现状,这里只做风险提示:SwiftUI 不保证 body 的求值次数与时序, 而它在这里既做重计算又改状态。后果是采样速度取决于无关的重绘(例如键盘、动画、状态变化), 不同机型/系统版本表现不一致,且日志与崩溃栈会难以归因。

建议:短期在 body 顶部加注释说明依赖(现状已如此,至少别让后来者以为这是纯渲染); 中期把管线搬到一个由 AsyncStream<CGImage> 驱动的 Task 里 —— 数据源本来就是流式的 (CameraManager.previewStream),现在等于把流又倒回渲染循环里。

M10. 相机权限被拒时扫码页是一片黑,且没有任何提示

  • XuXuCheck/qrcode/ScannerViewController.swift:200-220 的 handleCameraPermission(): .denied → didFail(reason: .permissionDenied)(即回调 .failure); .restricted 与 default 分支直接 break,连回调都不发。
  • 但两个调用点都只写了一半(已核对原文):
    if case let .success(result) = response { ... }   // 没有 else
    
    —— XuXuWebView.swift:154、MineView.swift:157。

后果:用户在系统设置里拒绝过相机权限后点「扫一扫」,sheet 弹出后是全黑界面 (setupCaptureDevice() 未被调用,previewLayer 永远不加入),没有文字、没有跳设置页的入口, 只能下滑关闭;.restricted 的情况连失败回调都没有,App 侧无从兜底。

建议:两个调用点补 else 分支,用 toast / alert 提示「未获得相机权限,请到系统设置开启」并给一个跳转 UIApplication.openSettingsURLString 的按钮;ScannerViewController 的 .restricted 也补一个明确的 .failure。

M11. CachedAsyncImage 每次 init 都新建一个 URLSession,且从不 invalidate

  • XuXuCheck/utils/CachedAsyncImage.swift:296-300:
    let configuration = URLSessionConfiguration.default
    configuration.urlCache = urlCache
    self.urlSession = URLSession(configuration: configuration)
    
    全文没有 invalidateAndCancel()(该文件 grep invalidate 零命中)。
  • 调用点:navigation/NavigationBar.swift:32(由 HomeView.swift:40 挂载,而 HomeView 的 body 由滚动驱动 —— :65-73 的 onPreferenceChange 里 withAnimation { hasScroll = ... })、 Mine/Views/MineView.swift:64。
  • CachedAsyncImage 是 struct,urlSession 是 let:父视图每次重算 body 就重新构造一个 URLSession (连带一份 URLSessionConfiguration 拷贝,含 cookie storage 与连接池)。

后果:Home 页滚动期间按帧频发生,未完成的 .task 还会把旧 session 连带结构体 retain 到请求结束 —— 表现为持续的分配与连接池抖动(不是一次性泄漏,所以 Instruments 的 Leaks 模板看不出来)。

建议:把 session 提成 static let shared(配 URLCache.shared 即可,它的容量本就有上限); init 里同步读 URLCache.shared.cachedResponse(for:)(:306-312)也建议挪出主线程路径。

M12. Toast 的自动消失任务在 body 求值时被重复派发,onToastDismiss 会调用多次

  • XuXuCheck/toast/MessageView.swift:241-250 在 presentSheet() 里 (presentSheet() 是 body(content:) 的一部分,:196):
    dispatchWorkHolder.work?.cancel()
    dispatchWorkHolder.work = DispatchWorkItem(block: { self.isPresented = false; self.onToastDismiss() })
    if isPresented, let work = dispatchWorkHolder.work { DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: work) }
    
  • 关键在于 :79 var dispatchWorkHolder = DispatchWorkHolder():DispatchWorkHolder 虽然是 class (toast/DispatchWorkHolder.swift:11),但它只是 MessageView(struct)的一个普通字段。 每次父视图重算、重新执行默认值时都会拿到一个新的 holder,所以 work?.cancel() 取消不到 上一次已经派发出去的那个 item。
  • 调用点:Camera/CameraView.swift:137-143(type: .toast, duration: 6.0, onToastDismiss: { dissmiss() })、 Login/Views/LoginBuilder.swift:200-206。CameraView 的 body 就是每帧重算的相机处理循环(见 M9), 触发条件最充分。

后果:toast 显示期间父视图每次重算都新派发一个 6 秒后执行的任务,到点后每一个都会执行 onToastDismiss() —— 它被调用 N 次(N ≈ 6 秒内的 body 重算次数),dissmiss() 被重复调用; 用户提前点掉 toast 后,残留在主队列里的任务仍会在几秒后再次触发。每个待执行任务还强持有捕获的 self, 把闭包里的对象多留 6 秒。

建议:holder 改成 @State(或直接 @State private var dismissTask: Task<Void, Never>?, 用 Task + try await Task.sleep 替代 DispatchWorkItem),让「取消上一次」真正生效。

M13. 【C++ 管线】calGrayImgDefinition 的行均值是累计和,清晰度门槛形同虚设

  • 位置:XuXuCheck/opencv/ImgLocateDryChemistry.cpp:726-750
    multiple= val= pre=diff = 0;                    // 728:只在进入行循环前初始化一次
    for (int y = 0; y < absSum.rows; ++y) {
        for (int x = 0; x < absSum.cols; ++x) { val += pData[y * step + x]; }
        val = val / absSum.cols;                    // 734:val 没有在行首归零
        ...
        if (val > 180) up++;//大值的行数
        if (abs(val - pre) > diff) { multiple = max(val, pre) / (min(val, pre) + 0.0001); ... }
        pre = val;
    }
    if (multiple > m && up > u) return true;
    
    第 y 行拿到的是第 0..y 行的累计均值,不是该行均值;pre / multiple(相邻行比值)也建立在累计值上。 从注释「大于均值的行数」「大值的行数」看,原意显然是逐行均值。

后果:累计和随行数线性增长,val > 180 在图像下半部分的几乎所有行都成立 → up 恒接近总行数, up > u 这一条被架空;multiple 也随之失真。而 calGrayImgDefinition 正是 试纸清晰度门槛(analyseDefinition:659 调用它,决定这一帧是否被接受)—— 门槛被放宽意味着接受本应被拒绝的模糊帧,进而输出错误测量值。 注意同名函数 calImgDefinition(:755-790)有同样的写法,但只被注释掉的代码调用,是死代码。

建议:把 val = 0; 移到行循环的第一行(与 pre 的用法一起复核一遍)。

M14. 【C++ 管线】warpAffineImg 的纠偏结果对几何没有任何作用,每帧白跑一次全尺寸变换

  • 位置:XuXuCheck/opencv/ImgLocateDryChemistry.cpp:933 sImg = warpAffineImg(sImg); sImg 是 GetPosition 里的 hsv S 通道(入参,按值引用被改写), 但紧接着的 analyseDefinition(sImg, roi)(:659)完全忽略自己的 sImg 参数 —— 内部用的是未旋转的成员 src(641/642/648 行),采样位置也仍由未旋转的 lRotRect/rRotRect 推出。

后果:今天只是每帧多一次全尺寸 warpAffine + copyTo 的开销(相机场景下不便宜); 风险在于它是个「看起来生效、其实没有」的纠偏 —— 谁哪天把 analyseDefinition 改成用 sImg,几何会立刻错位。

建议:要么删掉这次 warpAffine,要么把 analyseDefinition 真正接到旋转后的图上 —— 二选一, 不要停在中间态。


四、🟡 低优先级 / 整洁度

L1. Optional(...) 被直接渲染到界面上

  • XuXuCheck/Camera/CameraView.swift:213:Text("\(cameraViewModel.xuxuError?.message)" + "!")
  • XuXuCheck/Login/Views/LoginBuilder.swift:239:同样的 xuxuError?.message 拼进 toast

xuxuError 是可选类型,插值出来是 Optional("手机号已注册") 这种带 Optional(...) 外壳的字符串。 建议:cameraViewModel.xuxuError?.message ?? ""。

L2. 本地化覆盖严重不足(与 CLAUDE.md 的约定不一致)

CLAUDE.md 要求「所有面向用户的文案走 localizeString(_:),不要裸字面量」,实测:

Text("           44 处(其中 4 处含 \( )插值)
localizeString(  11 处

即界面文案里裸英文串的数量是走本地化通道的近 4 倍。Localizable.xcstrings 既然已备了 en + zh-Hans, 这些裸串在中文系统下不会被翻译。集中在: Camera/TimerCountView.swift(整页说明文字)、Login/Views/LoginBuilder.swift(登录/注册全流程)、 Camera/CameraView.swift、Mine/Views/MineView.swift(菜单项文字)。

建议:低风险地分批替换,优先登录流程(用户第一眼看到的界面)。

L3. 死代码清单(全仓 0 引用,已逐符号 grep 确认)

文件 / 符号 位置 说明
TabBarView.swift navigation/TabBarView.swift 旧的 TabView 版导航条,ContentView 用的是 TabBar
UnitView + DemoView Mine/Views/UnitView.swift 整个文件无人引用(仅注释里出现)
ImagePickerView utils/ImagePickerView.swift 仅出现在 MineView.swift:189,270 的注释中
ModelProtocol Model/protocol/ModelProtocol.swift 声明了 newJSONDecoder/newJSONEncoder,无任何类型遵守
getUserId() utils/Globe.swift 全局 var userid 与它一起废弃(实际用 Storage.shared.userid)
convertBase64ToImage utils/ XuXuWebView.swift:139 只有注释引用
isButtonVisible / loadUrl Model/WebViewModel.swift 等 上游 WKWebView 示例的残留
systemName / systemVersion utils/Globe.swift:63,66 见 H2,疑似接错线的残留
CameraManager.rotate(by:from:) Camera/CameraManager.swift 未调用
Web_Assets/ 项目根 上游示例的本地 HTML/CSS/图片,仅在 url == nil 时加载

建议:死文件整体删除(本目录不是 git 仓库,删前先备份);Web_Assets/ 若保留, 至少在 README 里注明它是示例脚手架。留着不删的真实代价是:下一个人会花时间研究哪个导航条才是生效的那个。

L4. NavigationLink(value:) 被当作 Label 使用,且没有对应的 navigationDestination

  • XuXuCheck/Mine/Views/MineView.swift:147,177,196,233,259,形态统一为
    Button(action: { isXxxPresented.toggle() },
           label: { NavigationLink(value: UUID()) { Label("Settings", systemImage: "gear") } })
    
  • 全仓没有任何 navigationDestination(for: UUID.self),即这些链接的目的地根本不存在。

目前能工作,是因为外层 Button 的 action 抢先执行了 fullScreenCover;但「控件套控件」在 SwiftUI 里 行为不保证 —— 不同 iOS 版本上出现过里层 NavigationLink 吞掉点击、按钮无反应的情况。 这里 NavigationLink 唯一的作用看起来只是借用它的 Label 渲染样式。

建议:.buttonStyle(PlainButtonStyle()) 下 Button { } label: { Label(...) } 本身就够用, 去掉里层的 NavigationLink。

L5. 设备族包含 visionOS,且 iPad 允许全方向

  • XuXuCheck.xcodeproj/project.pbxproj:三个 target 都写着 TARGETED_DEVICE_FAMILY = "1,2,7" (7 = visionOS),同时 INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait
    • ..._iPad = "四个方向全开"。

本 App 是 iPhone 竖屏交互设计:CameraView 的取景、TabBar 的 offset(y: 200) 硬位移、 XuXuWebView 的 topSafeInset = 20 都是按竖屏标定的。iPad 上允许横屏意味着这些布局要从没被验证过的方向 上跑一遍;visionOS 更是与本项目无关(SDK 是 iphoneos,这一项纯属从模板带过来的噪声)。

建议:TARGETED_DEVICE_FAMILY = "1"(只支持 iPhone,除非确实要上 iPad), 或至少让 iPad 也仅竖屏(..._iPad = UIInterfaceOrientationPortrait)。

L6. app_version 硬编码,与包版本无关联

  • XuXuCheck/api/Router.swift:61:"app_version": "4.2.0.2025.0607"(字符串字面量)。

它和 CFBundleShortVersionString 没有任何绑定关系,发版时改工程配置不会同步到这里, 两边会悄悄分叉,而服务端大概率用它做版本统计。

建议:从 Bundle.main.infoDictionary["CFBundleShortVersionString"] 取。

L7. Storage.shared 是 @AppStorage 包装,但被 View 当普通属性读(不触发刷新)

  • utils/Storage.swift 用 @AppStorage 存 token / 登录态 / 当前成员;但 navigation/NavigationBar.swift:23,32、Home/Views/HomeView.swift:40、Mine/Views/MineView.swift:95 是直接读属性。@AppStorage 的属性包装器只有在 SwiftUI 视图属性里声明时才具有响应性, 包在一个普通 class 里读是一次性取值。

现在界面仍能更新,是因为这些位置附近恰好还有别的 @Observable 状态在同一次事件里变化、顺带触发了重绘。 这属于「靠巧合正确」,改动周围代码时很容易失效。

建议:把需要驱动 UI 的状态收敛到 LoginStatus / Status 这类 @Observable 里 (isLogin 已经是这么用的),Storage 只做持久化和非 View 代码的读取 —— CLAUDE.md 里其实已经这么描述了,只是视图层没有完全遵守。

L8. 一点澄清:相册权限键缺失不会崩,但建议补上

  • 工程只声明了 INFOPLIST_KEY_NSCameraUsageDescription,没有 NSPhotoLibraryUsageDescription;
  • utils/ImagePickerViewModel.swift:17-20 的 choosePhoto() 会走 .photoLibrary(由网页的 selectphoto 命令触发,Components/XuXuWebView.swift:96-99)。

我一开始把这判为崩溃,核对后修正:UIImagePickerController 自 iOS 11 起改用进程外的系统选择器, App 不直接访问相册,因此该键并非必需 —— 当前不会崩(参考 Apple 开发者论坛讨论、 Chromium 的对应改动记录): 真正的隐私崩溃点在 NSPhotoLibraryAddUsageDescription(写入相册),而本 App 没有保存图片的动作。

所以这属于「没坏,但不该赌」:它是一行配置,而一旦换成 PHPicker 以外的直读相册(或某些 系统版本改变行为),缺失该键就是硬崩溃。建议补上,并顺手在真机上验证一次 selectphoto / selectcamera 两条路径。

L9. vendored 第三方代码「引进来得多、用得上得少」

四块 vendored 代码里,本 App 真正执行的只有很小一部分;其余是可以整体裁剪的维护面:

模块 体量 本 App 实际使用
DynamicColor/ 1,672 行 约 50 行:只用到 SwiftUI/Color.swift 的 init(hex: UInt64) 与 init(hexString:)。Core/(HSL / Lab / XYZ / 混色 / 渐变,约 1,400 行)、Shared/、SwiftUI/DynamicColor+Color.swift 全部零引用
qrcode/ — CodeScannerView / ScannerViewController(2 个调用点)。QRScanView.swift 整个文件无引用;ScannerViewController 的相册分支与手动拍摄分支在本 App 的参数组合下不可达(两个调用点都用默认值:manualSelect: false、isGalleryPresented: .constant(false))
toast/ — 只有 .toast 一种类型活着。.alert / .floater / .leftToastView / .rightToastView 只被 Mine/Views/UnitView.swift:96-132 使用,而那是死文件(见 L3)—— 连带 MessageView.swift:296-305 的左右 toast 路径也打不到
utils/CachedAsyncImage.swift — 2 个调用点,只用到 init(url:content:placeholder:) 这一族;scale / transaction / urlCache 参数与另两个 init 都没人用

建议:DynamicColor/Core、Shared、QRScanView.swift、UnitView.swift 可以直接删掉 (删前确认 Color(hex:) 的两个入口仍能编译 —— 注意 utils/exts.swift:21 另有一个 Color(hex: String),调用点解析到哪里要按标签判断,这也是 CLAUDE.md 记过的坑)。 不确定的(如 ScannerViewController 的相册分支)先留着,但值得在文件头注明「本 App 不可达」。

另外这两处是已确认不会触发、不必处理的:vendored 代码里 DynamicColor/Core/DynamicColor+RGBA.swift:66 的 fatalError、Shared/Utils.swift:74 的 UInt32(rounded) trap、Core/DynamicGradient.swift:61 的 colors[0] 越界 —— 都在上述零引用路径上。

L10. 【C++ 管线】若干潜在隐患(今天不可达,但都是「下一次改动就踩」的形态)

按「现在会不会出事」分成三档。第一档不需要现在动手,但值得知道它们在那儿:

A. 当前不可达,但埋在那儿

位置 问题
opencv/ContourSelector.cpp:65 v.first = &pContour; 存的是局部变量的地址,而 ImgLocateDryChemistry.cpp:347-348 把这个 pair 存进成员 lRectContour/rRectContour,函数返回后 first 立即悬垂。全仓确认无人解引用 .first(只用 .second),所以今天只是隐患 —— 但成员里长期保存着悬垂指针
opencv/ImgResult.h:16 @property UIImage* roiImg; 位于 NS_ASSUME_NONNULL_BEGIN 内(导入 Swift 为非可选),而唯一的赋值 OpencvWrapper.mm:103 是注释掉的 → 将来任何 result.roiImg 会立刻 trap
opencv/ImgLocateDryChemistry.cpp:8-11 PaperConfig() { PaperConfig(0,0,0,0,...); } 想写委托构造,实际只是构造了一个临时对象,成员全部未初始化。当前 10 个配置都手写全部 8 个字段所以被掩盖;Init()(:23-33)也无人调用
opencv/ImgResult.m:16-17 [NSMutableArray arrayWithObjects:@[@0,@0,@0,@0], nil] 实际只建了 1 个元素;长度之所以是 4,是因为 setObject:atIndexedSubscript: 在 index == count 时会追加。4 槽契约依赖「先写 0 再写 1 再写 2 再写 3」的书写顺序,改成 replaceObjectAtIndex: 或提前 return 就会变成 NSRangeException
OpencvWrapper.mm:116-129 paperTypeDesc[3] 写的是 double NSNumber,而 Swift 侧是 as! [Int](M8)。今天不崩,是因为整数取值的 double 能桥接成 Int(实测 NSNumber(value: 0.0) 可以、1.5 返回 nil)—— 靠的是巧合;该槽目前也没被使用(Router.swift 只取 desc[0..2])
opencv/ImgLocateDryChemistry.cpp:63-187 initPaperConfig 里 10 个 new PaperConfig(),而 ~ImgLocateDryChemistry(:59-61)是空的,mapPaperConfig 从不释放。因为 imgLoc 是全局、只构造一次,泄漏量固定且很小,属于一次性泄漏
opencv/Utils.cpp:55,105-107,12-13,139-140 未初始化的 maxR/maxG/maxB 参与 max(...);count == 0 时 Bm /= count 得 NaN;Rect(wbPt.x-5,...) 同样无边界检查;GetHModal 在空 map 上解引用 end()。这些函数(Whitebalance/WhitebalancePR/GetHModal)全仓无调用点 —— 但 Utils.cpp 因为 Xcode 16 的 PBXFileSystemSynchronizedRootGroup 仍会被编进 target
opencv/Comparer.h:39-44 case LeftTopX 取的是 getLeftTop().y(复制粘贴错误,应为 .x);该模板实例未被使用
OpencvWrapper.mm:57,91 cv:Scalar scalar(0); 是笔误产生的语句标签 cv: + 变量声明,能编译,但下次谁照着写就会踩坑

B. 待确认(需要构造特定输入,我没有确认触发路径):ImgLocateDryChemistry.cpp:947-948 的 sinVal/cosVal = sqrt(dy2/(dx2+dy2)),当两个定位块中心重合时是 0/0 → NaN,之后 (int)NaN 是 UB。 夹角过滤(:342)要求拟合直线近水平、RETR_EXTERNAL 又只取外轮廓,所以不易构造,列此备查。

C. 已排除的两处(记下来免得下次重新查一遍):

  • OpencvWrapper.mm:94 的 cvtColor(mat, srcImgMatBgra, COLOR_RGBA2BGRA) 一度很像崩溃点 (若源图少于 4 通道会抛异常)。已通过 lipo + ar 取出 vendored 静态库里的 apple_conversions.o 反汇编确认:CGImageToMat 走的是 create(rows, cols, CV_8UC4),即 UIImageToMat(...,false) 返回 4 通道 —— 不会抛。
  • 包装层的 COLOR_RGBA2BGRA 与 C++ 侧的 COLOR_RGBA2RGB 有双重「错标」, 但两者互相抵消,两边自洽,不是缺陷。

线程安全:无发现。 imgLoc(OpencvWrapper.mm:35)与全局 result(:80)虽然都是共享可变全局, 但 +analysisImage: 只从 CameraView.body(主线程)调用,相机帧经 CameraViewModel 的 Task { @MainActor } 落到主 actor,不存在并发访问。真正的隐患是同一个对象被每帧覆写(见 H8), 不是数据竞争。


五、架构层面的两点提醒(非缺陷)

  1. X-Sign 提供不了安全保证。utils/Globe.swift 的 getSign(time:) 用硬编码盐 ("urinalysis")在客户端算 MD5 切片,盐本身可以从 IPA 里直接挖出来。 它现在的实际作用只剩「让随意构造的请求缺一个字段」,不构成防伪造。真正的身份校验只能靠 Authorization: Bearer 在服务端做 —— 如果当初设计签名是为了防篡改,这个目标没有达成, 值得和接手服务端的人对齐一次预期。

  2. 网页桥的命令分发要在两处同步修改(WebViewRepresentable.swift 与 XuXuWebView.swift), CLAUDE.md 已记录这条约束。当前两个 switch 里 print / tel / clearcache 都是空实现(网页就算调了也没有任何反应),goback / scanpaper 只在 SwiftUI 那一侧有实现。 建议把这类「已废弃但保留的分支」明确注释掉或删除,避免下一个人以为它们生效。


六、建议的修复顺序

顺序 项 理由
1 H6 + H7(采样边界与下标) 同一段代码的两半:一处加边界判断、一处合并循环。唯一会直接杀进程的问题,且触发条件平凡(试纸靠边)
2 H5(扫码回调回主队列) 一处 DispatchQueue.main.async,换来真机扫码流程可靠;风险最低、收益最高
3 H8(ImgResult 每帧复位) 一行改动,消除「上传上一条试纸的测量值」
4 H3(删日志)、M2(as? String) 改动同样很小,一个防隐私泄露、一个防远端崩溃
5 H1(验证码回调)、M1(login 登录态)、H2(设备信息)、H4(首页链接冻结) 「功能静默失效」与错误数据,都是小改动
6 M3(相机可用性判断)、M4(状态栏)、M10(权限被拒提示)、M13(清晰度门槛) 边界崩溃、可见缺陷与判读质量
7 M5、M7、M8、M11、M12、M14 性能与健壮性,可随迭代做
8 L 类 建议单独开一次「清理提交」,不要和功能改动混在一起

第 1~5 条全部是小改动(单处几行到十几行),建议合成一次提交 + 一次真机回归。 回归重点:① 试纸故意放在画面最边缘扫一次(验 H6/H7);② 连续扫两条型号不同的试纸(验 H8); ③ 扫码后网页是否收到条码(验 H5);④ 登录 → 退出 → 首页四入口(验 H1/H4/M1)。


七、本轮之前已修复(不重复列出)

  • 底部导航条在网页退出登录后不再出现 —— showNavDetail 语义是反的,原先只有 goback 会复位。 已在 Components/XuXuWebView.swift 的 logout 分支补复位,并把 onAppear/onDisappear 成对绑上。
  • 三个未被引用的 pod(ProgressHUD / SDWebImage(+SwiftUI) / WrappingHStack)已移除, 嵌入框架 7 个/6.4 MB → 3 个/2.8 MB。
  • pod install 会破坏构建这个潜伏问题已修:podspec 声明的最低部署目标是 8.0,而 Xcode 27 只接受 15.0~27.0,原先靠手工改 Pods.xcodeproj(22 处)绕过,一重新生成就失效。现由 Podfile 的 post_install 钩子统一设置。

八、本次审查未覆盖的部分

三块代码(App Swift / C++ 管线 / vendored 第三方)都已审完,以下是仍然无法靠静态审查断言的部分:

  • 服务端行为:H4 里服务端拿到过期 token 的处理、H1 里业务错误的返回形态、 M1 里 login 命令的预期语义 —— 都需要联调确认。这几条我在正文里都标了「待确认」。
  • 真机验证:H5(非主线程回调的实际表现)、H6(试纸靠边时的崩溃)、M10(权限被拒后的黑屏) 在模拟器上测不出来或表现不同,必须在真机上按第六节的回归清单走一遍。
  • 性能与包体:只指出了 M5 / M11 / M14 这类明确的浪费,没有做 Instruments 实测; 包体方面 Frameworks/opencv2.xcframework 里那两个 visionOS 切片(约 106 MB)是纯死重, 删不删取决于是否还要保留 visionOS 构建能力 —— 这属于决策,不是缺陷。
  • opencv/ 里两处我无法构造触发路径的项(L10-A 的悬垂指针是否被间接使用、L10-B 的 0/0), 按「隐患」而非「缺陷」列出。

最后提醒:本目录不是 git 仓库,没有历史可比对。上述任何改动动手前,请先整包备份。