# iOS 学习信息采集 总设计 > IOS-INFO-000 | v1.0 | 2026-06-08 ## 1. 概述 iOS 端学习信息采集系统的目标:从 MaterialReader 生命周期的各个环节采集阅读行为数据,经 Rust document runtime 生成事件,再经本地上传队列可靠投递到 API 服务端。 ### 职责边界 ``` ┌─────────────────────────────────────────────────────────┐ │ iOS App │ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │ │ Reader View │──▶│ Rust Runtime │──▶│ UploadQueue │ │ │ │ (Lifecycle) │ │ (Event V2) │ │ (ack/retry) │ │ │ └─────────────┘ └──────────────┘ └──────┬──────┘ │ │ │ │ │ ┌─────────────┐ ┌──────────────┐ │ │ │ │ Continue │ │ Progress │ │ │ │ │ Learning │ │ Restore │ │ │ │ └─────────────┘ └──────────────┘ │ │ └───────────────────────────────────────────────┼────────┘ │ POST /learning/reading-events/batch │ ┌───────▼────────┐ │ API Server │ │ (M8) │ └────────────────┘ ``` **Rust 负责:** 事件生成(UUID/sequence/delta)、position normalize、session 管理、buffer 状态机 **iOS 负责:** 生命周期触发、readingTargetType 补充、上传队列、ack/reload、位置缓存、UI 联动 ## 2. V1 → V2 迁移 ### 当前状态(V1) ```swift // ReadingEventCollector.swift — V1 API pushReadingEvent(event: ReadingEvent.materialOpened(...)) exportPendingEvents() // → [ReadingEvent] clearExportedEvents(count: n) // 无 ack,无重试 ``` ### 致命问题(F1-F4 审查发现) | ID | 问题 | 影响 | |----|------|------| | F1 | `open/close` 被注释 | 事件链路断裂,无数据 | | F2 | 无上传队列 | 事件丢失无重试 | | F3 | 继续学习硬编码 | 无法动态更新 | | F4 | 位置恢复仅本地 | 跨设备无法同步 | ### 目标状态(V2) ```swift // ReadingRuntimeSessionManager.swift — V2 API let sid = try startReadingSessionV2(material: material, timestampMs: now) let ev = try pushMaterialOpenedV2(sessionId: sid, materialId: id, timestampMs: now) let batch = exportPendingEventsV2(limit: 100, timestampMs: now) ackEventsV2(eventIds: ids) // 成功后确认删除 markEventsFailedV2(eventIds: ids) // 失败后标记重试 reloadStaleEventsV2() // App 启动恢复 ``` ### 迁移对照 | V1 API (废弃) | V2 API (使用) | |--------------|-------------| | `pushReadingEvent(ReadingEvent.materialOpened)` | `pushMaterialOpenedV2(sessionId, materialId, ts)` | | `pushReadingEvent(ReadingEvent.heartbeat)` | `pushHeartbeatV2(sessionId, materialId, delta, pos, ts)` | | `exportPendingEvents()` | `exportPendingEventsV2(limit, ts)` | | `clearExportedEvents(count)` | `ackEventsV2(eventIds)` | | — | `markEventsFailedV2(eventIds)` | | — | `reloadStaleEventsV2()` | | `activeSeconds` (累计) | `activeSecondsDelta` (增量) | ## 3. 核心类型 ### ReadingMaterialContext ```swift struct ReadingMaterialContext { let materialId: String let readingTargetType: ReadingTargetType // knowledge_source | temporary_file let title: String? let knowledgeBaseId: String? let materialType: MaterialType // Rust MaterialType let previewMode: PreviewMode // Rust PreviewMode } enum ReadingTargetType: String { case knowledgeSource = "knowledge_source" case temporaryFile = "temporary_file" } ``` ### ReadingRuntimeAdapter 协议 ```swift protocol ReadingRuntimeAdapter { // Session func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String func closeSession(_ sessionId: String) throws func pauseSession(_ sessionId: String) throws func resumeSession(_ sessionId: String) throws // Events func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 // Buffer func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] func ackEvents(_ eventIds: [String]) -> UInt32 func markFailed(_ eventIds: [String]) -> UInt32 func reloadStale() -> UInt32 func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 } ``` ### ReadingEventUploadItem ```swift struct ReadingEventUploadItem: Codable { // From Rust ReadingEventV2 let eventId: String let clientSessionId: String let materialId: String let eventType: String let position: ReadingPosition? let activeSecondsDelta: Int let clientTimestampMs: Int64 let sequence: UInt64 // iOS supplements let readingTargetType: String let platform: String = "ios" let appVersion: String let clientTimezoneOffsetMinutes: Int } ``` ## 4. 架构:ReadingRuntimeSessionManager ```swift @MainActor final class ReadingRuntimeSessionManager { static let shared = ReadingRuntimeSessionManager() private var activeSessionId: String? private var context: ReadingMaterialContext? private var heartbeatTimer: Timer? private var lastPosition: ReadingPosition? // MARK: - Lifecycle func openMaterial(_ ctx: ReadingMaterialContext) func closeMaterial() func appDidEnterBackground() func appWillEnterForeground() // MARK: - Position func updatePosition(_ pos: ReadingPosition) // debounced func markAsRead() // MARK: - Heartbeat func startHeartbeat(interval: TimeInterval = 15) func stopHeartbeat() } ``` ## 5. 上传队列:ReadingEventUploadQueue ### 状态机 ``` Rust export │ ▼ LocalQueue (Swift array, persisted to disk) │ ▼ POST /learning/reading-events/batch │ ┌─────┴─────┐ ▼ ▼ 成功(200) 失败/网络错误 │ │ ▼ ▼ ackEvents markFailed (Rust delete) (Rust retry mark) │ │ ▼ ▼ remove from keep in queue local queue for next attempt ``` ### UploadQueue 核心方法 ```swift final class ReadingEventUploadQueue { func enqueue(_ items: [ReadingEventUploadItem]) func flush() async -> FlushResult // called on timer + app background func retryFailed() async -> FlushResult // called on network recovery func reloadOnLaunch() // reloadStaleEventsV2 + load persisted } struct FlushResult { let uploaded: Int let failed: Int let warnings: [String] } ``` ## 6. 关键时机 | 时机 | 触发方 | 动作 | |------|--------|------| | MaterialReader onAppear | SwiftUI | `openMaterial(ctx)` | | MaterialReader onDisappear | SwiftUI | `closeMaterial()` | | Scroll/page change | Reader View | `updatePosition(pos)` | | Tap "Mark as Read" | UI Button | `markAsRead()` | | App → background | ScenePhase | `flush()` + stop heartbeat | | App → foreground | ScenePhase | `reloadStale()` + `cleanupStaleSessions()` | | Network recovery | NWPathMonitor | `retryFailed()` | | Every 30s (timer) | UploadScheduler | `flush()` | | On launch | App init | `reloadOnLaunch()` | ## 7. 继续学习 ### 当前(F3 硬编码) ```swift // 硬编码"推荐继续阅读"文案,无数据源 ``` ### 目标 ```swift struct ContinueLearningService { func fetch() async -> ContinueLearningItem? { let resp = try await api.get("/learning/continue") switch resp.type { case "knowledge_source", "temporary_file": return ContinueLearningItem( materialId: resp.materialId, title: resp.title ?? "Untitled", lastPosition: resp.lastPosition, lastProgress: resp.lastProgress ) case "none": return nil default: return nil } } } ``` ## 8. 阅读位置恢复 ### 当前(F4 仅本地) ```swift // ReadingPositionStore: UserDefaults only ``` ### 目标 ```swift struct ReadingPositionRestoreService { func restore(for materialId: String, targetType: ReadingTargetType) async -> ReadingPosition? { // 1. Try API: GET /materials/:id/reading-progress if let remote = try? await api.getProgress(materialId, targetType), remote.status != "not_started", let pos = remote.lastPosition { return pos } // 2. Fallback: local cache (ReadingPositionStore) return ReadingPositionStore.shared.get(for: materialId) } } ``` ## 9. 分析页对齐 | API | iOS 界面 | |-----|---------| | GET /learning/summary | AnalysisHomeView 顶部卡片 | | GET /learning/trend?days=7 | 趋势曲线 | | GET /activity/heatmap?days=365 | 热力图 | ## 10. 错误处理 | 错误码 | iOS 动作 | |--------|---------| | MATERIAL_ACCESS_DENIED | 不重试,从队列移除,toast 提示 | | TEMPORARY_MATERIAL_EXPIRED | 从队列移除 | | DUPLICATE_EVENT | 直接 ack 移除 | | SOURCE_DELETED | toast 提示,从队列移除 | | INVALID_* | 从队列移除,记录日志 | | 网络错误 | 保留队列,等网络恢复后 retry | | 5xx | 指数退避重试(1s/2s/4s/8s max 3 retry) | ## 11. 离线策略 - 事件始终写入 Rust buffer + 本地队列 - 无网络时 export 继续执行(Rust buffer 正常),upload 跳过 - 网络恢复时 `reloadStaleEventsV2()` + `flush()` - 本地队列持久化到文件(JSON),App 重启可恢复