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)
// ReadingEventCollector.swift — V1 API
pushReadingEvent(event: ReadingEvent.materialOpened(...))
exportPendingEvents() // → [ReadingEvent]
clearExportedEvents(count: n) // 无 ack,无重试
致命问题(F1-F4 审查发现)
| ID |
问题 |
影响 |
| F1 |
open/close 被注释 |
事件链路断裂,无数据 |
| F2 |
无上传队列 |
事件丢失无重试 |
| F3 |
继续学习硬编码 |
无法动态更新 |
| F4 |
位置恢复仅本地 |
跨设备无法同步 |
目标状态(V2)
// 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
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 协议
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
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
@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 核心方法
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 硬编码)
// 硬编码"推荐继续阅读"文案,无数据源
目标
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 仅本地)
// ReadingPositionStore: UserDefaults only
目标
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 重启可恢复