- Add zx_documentFFI bridging header + modulemap - Update zx_document.swift (patched for static linking + iOS 26 SDK) - Replace XCFramework with V2 symbols - Add V2 types: ReadingMaterialContext, ReadingEventUploadItem, ReadingRuntimeAdapter - Add V2 session manager: ReadingRuntimeSessionManager - Add V2 position adapter: ReadingPositionAdapter - Add V2 event mapper: ReadingEventMapper - Add upload queue + pipeline: ReadingEventUploadQueue - Add reading API client: ReadingAPI - Fix QuickNoteSheet for new NoteAnchor fields (positionSnapshot) - Restore MaterialReaderView lifecycle events (onAppear/onDisappear) - Add ScenePhase handling for app background/foreground Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
316 lines
11 KiB
Markdown
316 lines
11 KiB
Markdown
# 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 重启可恢复
|