# 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 伪代码 ```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 } // 转为 UploadItem(iOS 补充业务字段) 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) } } ``` ## 错误处理示例 ```swift 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) } } ``` ## 异常恢复示例 ```swift 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 去重) } ``` ## 验收清单 - [x] 包含 Swift 伪代码 - [x] 包含 export → queue → ack 顺序 - [x] 包含错误处理示例 - [x] 包含 mark failed 示例 - [x] 包含异常恢复示例 - [x] 明确 Rust 不上传 API - [x] 明确 readingTargetType 由 iOS 补充 - [x] 能指导 M-IOS-INFO 接入