feat(iOS): 更新MNN后端模型配置优化性能

将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本
实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX
兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。
```
This commit is contained in:
link2026
2026-06-09 22:20:07 +08:00
parent ca5a3fa38b
commit b79ae54b7b
40 changed files with 1327 additions and 452 deletions

View File

@@ -0,0 +1,457 @@
import SwiftUI
import SwiftData
/// bucket
/// - `.series`: seriesKey (//...)
/// - `.bloodPressure`:(bp.systolic + bp.diastolic )
/// - `.lab`: seriesKey /, name+unit key
enum IndicatorGroup: Identifiable, Hashable {
case series(key: String)
case bloodPressure
case lab(key: String)
var id: String {
switch self {
case .series(let k): return "series:\(k)"
case .bloodPressure: return "bp"
case .lab(let k): return "lab:\(k)"
}
}
/// ( SeriesBucket )
static func of(_ i: Indicator) -> IndicatorGroup {
if let key = i.seriesKey, !key.isEmpty {
return key.hasPrefix("bp.") ? .bloodPressure : .series(key: key)
}
return .lab(key: SeriesBucket.normalizedKey(name: i.name, unit: i.unit))
}
}
/// :,
/// @Query ,
struct IndicatorSeriesDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var ctx
let group: IndicatorGroup
@Query(sort: \Indicator.capturedAt, order: .reverse)
private var indicators: [Indicator]
@Query private var profiles: [UserProfile]
@Query private var customMetrics: [CustomMonitorMetric]
@State private var selection: String?
@State private var showTrend = false
@State private var showDeleteConfirm = false
@State private var evidenceTarget: Indicator?
// MARK: -
/// :;
private enum Record: Identifiable {
case single(Indicator)
case bp(sys: Indicator, dia: Indicator?)
var id: String {
switch self {
case .single(let i): return "\(i.persistentModelID)"
case .bp(let s, _): return "bp-\(s.persistentModelID)"
}
}
}
/// : bp.systolic , ±5s bp.diastolic( TimelineEntry )
private var bloodPressureRecords: [Record] {
let sysList = indicators
.filter { $0.seriesKey == "bp.systolic" }
.sorted { $0.capturedAt > $1.capturedAt }
var usedDia = Set<PersistentIdentifier>()
return sysList.map { sys in
let dia = indicators.first {
$0.seriesKey == "bp.diastolic" &&
!usedDia.contains($0.persistentModelID) &&
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
}
if let dia { usedDia.insert(dia.persistentModelID) }
return .bp(sys: sys, dia: dia)
}
}
private var records: [Record] {
switch group {
case .bloodPressure:
return bloodPressureRecords
case .series(let key):
return indicators
.filter { $0.seriesKey == key }
.sorted { $0.capturedAt > $1.capturedAt }
.map(Record.single)
case .lab(let nk):
return indicators
.filter {
($0.seriesKey ?? "").isEmpty &&
SeriesBucket.normalizedKey(name: $0.name, unit: $0.unit) == nk
}
.sorted { $0.capturedAt > $1.capturedAt }
.map(Record.single)
}
}
private var title: String {
switch group {
case .bloodPressure:
return String(appLoc: "血压")
case .series, .lab:
if case let .single(i)? = records.first { return i.name }
return String(appLoc: "指标详情")
}
}
/// bucket( 2 );nil
private var bucket: SeriesBucket? {
let all = SeriesBucket.build(from: indicators,
profile: profiles.first,
customMetrics: customMetrics)
switch group {
case .bloodPressure:
return all.first { $0.id == "bp" }
case .series(let key):
return all.first { b in b.lines.contains { $0.seriesKey == key } }
case .lab(let nk):
return all.first { $0.kind == .lab && $0.id == "lab:\(nk)" }
}
}
private var currentIndex: Int {
records.firstIndex { $0.id == selection } ?? 0
}
// MARK: - Body
var body: some View {
NavigationStack {
VStack(spacing: 0) {
header
if records.isEmpty {
Spacer()
TjPlaceholder(label: String(appLoc: "记录已不存在"))
.frame(width: 200, height: 120)
Spacer()
} else {
pages
pager
if bucket != nil { trendButton }
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.navigationDestination(isPresented: $showTrend) {
if let bucket { TrendDetailView(bucket: bucket) }
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.onAppear { if selection == nil { selection = records.first?.id } }
.alert(String(appLoc: "永久删除这条记录?"), isPresented: $showDeleteConfirm) {
Button(String(appLoc: "删除"), role: .destructive) { deleteCurrent() }
Button(String(appLoc: "取消"), role: .cancel) { }
} message: {
Text("删除后无法恢复。")
}
.sheet(item: $evidenceTarget) { indicator in
if let report = indicator.report {
EvidenceImagePreview(report: report, indicator: indicator)
}
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.tjScaled(16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text(title)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
.lineLimit(1)
if records.count > 1 {
Text("\(records.count)")
.font(.tjScaled(12))
.foregroundStyle(Tj.Palette.text3)
}
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
// MARK: -
private var pages: some View {
TabView(selection: $selection) {
ForEach(records) { rec in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
recordCard(rec)
deleteButton
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(Optional(rec.id))
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
@ViewBuilder
private func recordCard(_ rec: Record) -> some View {
switch rec {
case .single(let i): singleCard(i)
case .bp(let sys, let dia): bpCard(sys: sys, dia: dia)
}
}
private func singleCard(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.tjScaled(30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
if i.report != nil { evidenceButton(for: i) }
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
}
private func bpCard(sys: Indicator, dia: Indicator?) -> some View {
let combined: IndicatorStatus = sys.status != .normal ? sys.status : (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.tjScaled(30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.tjScaled(14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
}
// MARK: -
private var pager: some View {
VStack(spacing: 8) {
HStack(spacing: 20) {
pagerArrow("chevron.left", enabled: currentIndex > 0) {
if currentIndex > 0 { selection = records[currentIndex - 1].id }
}
if records.count <= 7 {
HStack(spacing: 6) {
ForEach(Array(records.enumerated()), id: \.offset) { idx, _ in
Circle()
.fill(idx == currentIndex ? Tj.Palette.ink : Tj.Palette.line)
.frame(width: 6, height: 6)
}
}
}
pagerArrow("chevron.right", enabled: currentIndex < records.count - 1) {
if currentIndex < records.count - 1 { selection = records[currentIndex + 1].id }
}
}
Text("\(currentIndex + 1) / 共 \(records.count)")
.font(.tjScaled(11, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
.padding(.top, 4)
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
}
private func pagerArrow(_ system: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: system)
.font(.tjScaled(13, weight: .semibold))
.foregroundStyle(enabled ? Tj.Palette.text : Tj.Palette.text3.opacity(0.4))
.frame(width: 30, height: 30)
.background(Circle().fill(Tj.Palette.sand2))
}
.buttonStyle(.plain)
.disabled(!enabled)
}
// MARK: - /
private var trendButton: some View {
Button { showTrend = true } label: {
Label(String(appLoc: "查看趋势图"), systemImage: "chart.xyaxis.line")
.font(.tjScaled(15, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.ink)
)
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
private var deleteButton: some View {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(String(appLoc: "永久删除"), systemImage: "trash")
.font(.tjScaled(12, weight: .medium))
.foregroundStyle(Tj.Palette.brick.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.brick.opacity(0.3), lineWidth: 1)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.top, 8)
}
/// (:SwiftData + Vault unlink, CLAUDE.md §6)
/// selection ;
private func deleteCurrent() {
guard records.indices.contains(currentIndex) else { return }
let removingIndex = currentIndex
switch records[removingIndex] {
case .single(let i):
deleteIndicator(i)
case .bp(let sys, let dia):
deleteIndicator(sys)
if let dia { deleteIndicator(dia) }
}
try? ctx.save()
let remaining = records
if remaining.isEmpty {
dismiss()
} else {
let next = min(removingIndex, remaining.count - 1)
selection = remaining[next].id
}
}
private func deleteIndicator(_ i: Indicator) {
if let asset = i.asset {
try? FileVault.shared.remove(relativePath: asset.relativePath)
ctx.delete(asset)
}
ctx.delete(i)
}
// MARK: -
@ViewBuilder
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) { content() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.tjScaled(13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.tjScaled(14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
@ViewBuilder
private func evidenceButton(for indicator: Indicator) -> some View {
if indicator.hasEvidenceBox,
let page = indicator.sourcePageIndex,
let assets = indicator.report?.assets,
assets.indices.contains(page) {
Button {
evidenceTarget = indicator
} label: {
Label(String(appLoc: "查看原图位置"), systemImage: "viewfinder")
.font(.tjScaled(12, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Tj.Palette.leaf.opacity(0.14)))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
private func statusChip(_ s: IndicatorStatus) -> some View {
let text: String
let color: Color
let arrow: String
switch s {
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = ""
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = ""
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.tjScaled(11, weight: .bold)) }
Text(text).font(.tjScaled(12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.14)))
}
private nonisolated static func dateTimeText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
}

View File

@@ -420,7 +420,8 @@ struct TimelineEntryDetailView: View {
}
}
private struct EvidenceImagePreview: View {
/// ( + ),
struct EvidenceImagePreview: View {
@Environment(\.dismiss) private var dismiss
let report: Report
let indicator: Indicator