ios-projects/AIStudyApp/docs/ios-learning-info-design.md
wangdl a6dde9f0c6 feat: M-IOS-INFO V2 reading event system + iOS 26 build fixes
- 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>
2026-06-09 19:58:57 +08:00

316 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()`
- 本地队列持久化到文件JSONApp 重启可恢复