# 代码审查报告 审查对象:`/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`: ```swift "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`: ```swift print(request.allHTTPHeaderFields ?? "") print(params ?? "") ``` 这是 `URLRequestConvertible` 的统一出口,所有请求都过;登录/注册的 `params` 里有 `password`、 `confirm_password`,登录后的每个请求头里都有 `Authorization: Bearer `。 - 另外 `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` ```objc 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` 边距的写法: ```cpp 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 侧也没有任何兜底。这不是「某台设备偶发」,而是「试纸放得靠边就崩」。 **建议**:在采样前把矩形夹到合法范围内并跳过不合法的点,例如 ```cpp 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`,两段循环紧挨着: ```objc 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` ```objc @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`: ```swift 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` 驱动的 `Task` 里 —— 数据源本来就是流式的 (`CameraManager.previewStream`),现在等于把流又倒回渲染循环里。 ### M10. 相机权限被拒时扫码页是一片黑,且没有任何提示 - `XuXuCheck/qrcode/ScannerViewController.swift:200-220` 的 `handleCameraPermission()`: `.denied` → `didFail(reason: .permissionDenied)`(即回调 `.failure`); **`.restricted` 与 `default` 分支直接 `break`,连回调都不发**。 - 但两个调用点都只写了一半(已核对原文): ```swift 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`: ```swift 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`): ```swift 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?`, 用 `Task` + `try await Task.sleep` 替代 `DispatchWorkItem`),让「取消上一次」真正生效。 ### M13. 【C++ 管线】`calGrayImgDefinition` 的行均值是**累计和**,清晰度门槛形同虚设 - 位置:`XuXuCheck/opencv/ImgLocateDryChemistry.cpp:726-750` ```cpp 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`,形态统一为 ```swift 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 开发者论坛讨论](https://developer.apple.com/forums/thread/81619)、 [Chromium 的对应改动记录](https://chromium.googlesource.com/ios-chromium-mirror/+/9a0dcbe48502ec530432141a009c4ff35a274acf)): 真正的隐私崩溃点在 `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 仓库,没有历史可比对。上述任何改动动手前,请先整包备份。