zhixi-document-runtime/docs/ios-reading-event-v2-integration.md
wangdl 5d6bf41cab fix: close_reading_session_v2 takes timestamp, sets closed_at_ms
Review finding #3: close now accepts timestamp_ms and sets closed_at_ms
automatically, removing the need for a separate set_session_closed_at_ms call.
set_session_closed_at_ms retained for override use cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 21:33:42 +08:00

8.4 KiB
Raw Blame History

iOS ReadingEvent V2 集成示例

概述

本文档提供 iOS 如何调用 Rust Document Runtime V2 事件链路的示例代码,明确 Rust → iOS → API 的职责边界和调用顺序。

核心原则

  1. Rust 不上传 API
  2. Rust 不知道 readingTargetType
  3. iOS 转 UploadItem 时补 readingTargetType / platform / appVersion / timezone
  4. iOS 写入本地上传队列成功后才 ack
  5. ack 不等 API 上传成功

完整流程

App 启动
  → reload_stale_events_v2()
  → cleanup_stale_sessions_ffi(now, maxAge)

进入阅读页
  → ReadingMaterialContext 构建 ReadingMaterialRefV2
  → start_reading_session_v2(materialRef, timestampMs)

阅读中(每 15s
  → push_heartbeat_v2(sessionId, materialId, delta, position?, timestampMs)

位置变化
  → push_position_changed_v2(sessionId, materialId, position, timestampMs)

标记已读
  → push_marked_as_read_v2(sessionId, materialId, timestampMs)

退出阅读页
  → push_material_closed_v2(sessionId, materialId, residualDelta, timestampMs)
  → close_reading_session_v2(sessionId, timestampMs)
  → export_pending_events_v2(limit, timestampMs)
  → 每条 event 转为 ReadingEventUploadItem → 写入本地队列 → ack_events_v2(eventIds)

Swift 伪代码

import ZxDocumentRuntime

// ── App Delegate / SceneDelegate ──

func applicationDidFinishLaunching() {
    // 恢复未 ack 的 exported 事件
    let recovered = reload_stale_events_v2()
    if recovered > 0 {
        os_log("Recovered %d stale events", recovered)
    }
    // 清理超时的旧 session如超过 60 分钟无活动)
    let now = Int64(Date().timeIntervalSince1970 * 1000)
    cleanup_stale_sessions_ffi(now_ms: now, max_age_ms: 60 * 60 * 1000)
}

// ── ReadingRuntimeAdapter ──

class V2ReadingRuntimeAdapter: ReadingRuntimeAdapter {

    func openSession(context: ReadingMaterialContext, timestamp: Date) async throws -> ReadingRuntimeSession {
        // 构建 Rust 侧 materialRef — 只传 Rust 需要的信息
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        let materialRef = ReadingMaterialRefV2(
            materialId: context.materialId,
            filePath: context.localFilePath,
            fileName: context.fileName,
            fileType: context.fileType
        )
        materialRef.mimeType = context.mimeType
        materialRef.fileSize = context.fileSize.map { UInt64($0) }

        let sessionId = try start_reading_session_v2(
            material: materialRef,
            timestampMs: ts
        )
        return ReadingRuntimeSession(id: sessionId, materialId: context.materialId)
    }

    func heartbeat(sessionId: String, timestamp: Date) async throws {
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        // delta 由 iOS Timer 计算传入,不是 Rust 自己算
        let delta = activeTimeTracker.calculateDelta(at: timestamp)
        _ = try push_heartbeat_v2(
            sessionId: sessionId,
            materialId: currentMaterialId,
            activeSecondsDelta: UInt32(delta),
            position: currentPositionDTO,
            timestampMs: ts
        )
    }

    func recordPositionChanged(sessionId: String, position: ReadingPositionDTO, timestamp: Date) async throws {
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        let rustPos = position.toRustReadingPosition()
        _ = try push_position_changed_v2(
            sessionId: sessionId,
            materialId: currentMaterialId,
            position: rustPos,
            timestampMs: ts
        )
    }

    func recordMarkedAsRead(sessionId: String, position: ReadingPositionDTO?, timestamp: Date) async throws {
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        _ = try push_marked_as_read_v2(
            sessionId: sessionId,
            materialId: currentMaterialId,
            timestampMs: ts
        )
    }

    func closeSession(sessionId: String, position: ReadingPositionDTO?, timestamp: Date) async throws {
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        // 1. Flush 事件
        try await flushPendingEvents(timestamp: timestamp)
        // 2. Close session (sets closed_at_ms automatically)
        try close_reading_session_v2(sessionId: sessionId, timestampMs: ts)
    }

    // ── Export + Queue + Ack ──

    func flushPendingEvents(timestamp: Date) async throws {
        let ts = Int64(timestamp.timeIntervalSince1970 * 1000)
        let batch = export_pending_events_v2(limit: 100, timestampMs: ts)

        guard !batch.isEmpty else { return }

        // 转为 UploadItemiOS 补充业务字段)
        let items = batch.map { event in
            ReadingEventUploadItem(
                eventId: event.eventId,
                clientSessionId: event.clientSessionId,
                readingTargetType: currentContext.readingTargetType,  // ← iOS 补充
                materialId: event.materialId,
                knowledgeBaseId: currentContext.knowledgeBaseId,      // ← iOS 补充
                eventType: mapEventType(event.eventType),
                position: event.position,
                activeSecondsDelta: Int(event.activeSecondsDelta),
                clientTimestamp: Date(ms: event.timestampMs),
                clientTimezoneOffsetMinutes: currentAppContext.timezoneOffsetMinutes,
                sequence: Int(event.sequence),
                platform: currentAppContext.platform,                  // ← iOS 补充
                appVersion: currentAppContext.appVersion               // ← iOS 补充
            )
        }

        // 写入本地队列(持久化)
        let writtenIds = try await localUploadQueue.append(items: items)

        // 只 ack 写入成功的 eventIds
        if !writtenIds.isEmpty {
            let acked = ack_events_v2(eventIds: writtenIds)
            os_log("Acked %d events", acked)
        }

        // 写入失败的 eventIds → mark failed下次 retry
        let allExportedIds = batch.map(\.eventId)
        let failedIds = allExportedIds.filter { !writtenIds.contains($0) }
        if !failedIds.isEmpty {
            let marked = mark_events_failed_v2(eventIds: failedIds)
            os_log("Marked %d events as failed", marked)
        }
    }

    // ── Buffer State ──

    func getBufferStats() -> EventBufferStateV2 {
        return get_event_buffer_state_v2()
    }
}

// ── Helper ──

extension Date {
    init(ms: Int64) {
        self.init(timeIntervalSince1970: Double(ms) / 1000.0)
    }
}

错误处理示例

func safeCloseSession(sessionId: String?) {
    guard let sid = sessionId else { return }

    // Best-effort: 即使失败也不阻塞页面关闭
    do {
        try await flushPendingEvents(timestamp: Date())
    } catch {
        os_log("Flush failed: %@", error.localizedDescription)
    }

    let ts = Int64(Date().timeIntervalSince1970 * 1000)

    do {
        try close_reading_session_v2(sessionId: sid, timestampMs: ts)
    } catch let error as SessionError {
        if error == .alreadyClosed {
            os_log("Session already closed, ignoring")
            return
        }
        os_log("Close failed: %@", error.localizedDescription)
    }
}

异常恢复示例

func handleAppReturnFromBackground() {
    let now = Int64(Date().timeIntervalSince1970 * 1000)

    // 1. 恢复 stale events
    let recovered = reload_stale_events_v2()
    if recovered > 0 {
        os_log("Recovered %d stale events — will re-export", recovered)
        // 触发 export → queue → ack 流程
    }

    // 2. 标记超时 session 为 interrupted
    let removed = cleanup_stale_sessions_ffi(now_ms: now, max_age_ms: 30 * 60 * 1000)
    if removed > 0 {
        os_log("Cleaned up %d stale sessions", removed)
    }

    // 3. 恢复本地队列上传
    localUploadQueue.resumePendingUploads()
}

func handleAppCrashRecovery() {
    // App 启动后:
    // 1. reload_stale_events_v2 把 iOS crash 前 export 但未 ack 的事件重置为 Pending
    // 2. cleanup_stale_sessions_ffi 清理 crash 前未 close 的 session
    // 3. localUploadQueue 中的事件不受影响已持久化eventId 去重)
}

验收清单

  • 包含 Swift 伪代码
  • 包含 export → queue → ack 顺序
  • 包含错误处理示例
  • 包含 mark failed 示例
  • 包含异常恢复示例
  • 明确 Rust 不上传 API
  • 明确 readingTargetType 由 iOS 补充
  • 能指导 M-IOS-INFO 接入