d6aeede2bc
从桌面工程复制而来,作为本仓库的 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。
689 lines
46 KiB
Markdown
689 lines
46 KiB
Markdown
# 代码审查报告
|
|
|
|
审查对象:`/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 <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`
|
|
```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<CGImage>` 驱动的 `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<Void, Never>?`,
|
|
用 `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 仓库,没有历史可比对。上述任何改动动手前,请先整包备份。
|