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

11 KiB
Raw Blame History

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