从桌面工程复制而来,作为本仓库的 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。
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 管线的几何与颜色链路也是自洽的,没有发现系统性的设计错误。问题集中在四类:
- 调试期的临时代码没有清理(打印、裸文案、注释掉的替代实现);
- 状态在多处各写一份且没有统一入口(登录态、导航条、首页链接、全局结果对象);
- 对远端/异步输入不做防护的强制转换(桥消息、OpenCV 返回值、扫码回调);
- 采样环节普遍缺边界校验(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 秒倒计时, 不等任何回调。
三者叠加的后果:
- 服务端返回业务错误(
model.code > 0,例如手机号已注册、发送过于频繁)时,在responseObject映射 成功的情况下response.error为 nil → 既不回调也不报错 → 界面无任何提示,而按钮已经进入 60 秒倒计时, 用户只能干等。CLAUDE.md 里「model.code > 0是业务错误」的约定在这个接口上完全没有被执行。 - 全仓 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": brandNameXuXuCheck/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:85webView?.evaluateJavaScript(...)。 -
回调来源:
XuXuCheck/qrcode/ScannerViewController.swift。这条链有点绕,逐步看:metadataOutput(_:didOutput:from:)(:440)运行在主队列 (:272setMetadataObjectsDelegate(self, queue: DispatchQueue.main));- 它并不直接上报结果,而是先组装
handler闭包(:453),found(result)的 5 处调用 (:459,465,472,477,482)全都在这个闭包内部; - 因为
requiresPhotoOutput默认为true(qrcode/CodeScanner.swift:100,本 App 两个调用点都没传),:487-490走的是photoOutput.capturePhoto(..., delegate: self),handler?(nil)那条路不可达; - 于是
handler唯一的实际触发点是:573—— 在AVCapturePhotoCaptureDelegate的photoOutput(_:didFinishProcessingPhoto:error:)里; - 而
AVCapturePhotoOutput的 delegate 回调不在主队列(SDK 头文件原话: "AVCapturePhotoOutput invokes the AVCapturePhotoCaptureDelegate callbacks on a common dispatch queue — not necessarily the main queue"); :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:155cv::Rect roi(pt.x - 5, pt.y - 5, 10, 10); Mat roiRgbMat(meanMat, roi); // 156pt来自 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,两段循环紧挨着:第二个循环恒从下标 0 开始取,过滤结果从未参与取值。 作者会写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);xs[i] > 0 && ys[i] > 0这个判断,说明实践中见过非正的坐标 —— 但这个判断对实际采样没有任何作用。 - 阈值也不够:它只要求
> 0,而安全下限是 5(矩形会向两侧各外扩 5px), 即x ∈ [1,4]的点会被判"有效"却照样产出负原点 → 走 H6 的崩溃。
后果(两个,都实际发生):
- 非法点照样进入
cv::Rect,即上面 H6 的崩溃; totalPt - 1的本意是排除末尾追加的白平衡点(ImgLocateDryChemistry.cpp:1034roiPositions.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! StringmessageFromWebPage.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:92videoOutput.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 { ... } // 没有 elseXuXuWebView.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()(该文件 grepinvalidate零命中)。- 调用点:
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第 y 行拿到的是第 0..y 行的累计均值,不是该行均值;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;pre/multiple(相邻行比值)也建立在累计值上。 从注释「大于均值的行数」「大值的行数」看,原意显然是逐行均值。
后果:累计和随行数线性增长,val > 180 在图像下半部分的几乎所有行都成立 → up 恒接近总行数,
up > u 这一条被架空;multiple 也随之失真。而 calGrayImgDefinition 正是
试纸清晰度门槛(analyseDefinition:659 调用它,决定这一帧是否被接受)——
门槛被放宽意味着接受本应被拒绝的模糊帧,进而输出错误测量值。
注意同名函数 calImgDefinition(:755-790)有同样的写法,但只被注释掉的代码调用,是死代码。
建议:把 val = 0; 移到行循环的第一行(与 pre 的用法一起复核一遍)。
M14. 【C++ 管线】warpAffineImg 的纠偏结果对几何没有任何作用,每帧白跑一次全尺寸变换
- 位置:
XuXuCheck/opencv/ImgLocateDryChemistry.cpp:933sImg = warpAffineImg(sImg);sImg是GetPosition里的hsvS 通道(入参,按值引用被改写), 但紧接着的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),
不是数据竞争。
五、架构层面的两点提醒(非缺陷)
-
X-Sign提供不了安全保证。utils/Globe.swift的getSign(time:)用硬编码盐 ("urinalysis")在客户端算 MD5 切片,盐本身可以从 IPA 里直接挖出来。 它现在的实际作用只剩「让随意构造的请求缺一个字段」,不构成防伪造。真正的身份校验只能靠Authorization: Bearer在服务端做 —— 如果当初设计签名是为了防篡改,这个目标没有达成, 值得和接手服务端的人对齐一次预期。 -
网页桥的命令分发要在两处同步修改(
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 仓库,没有历史可比对。上述任何改动动手前,请先整包备份。