From 254907194c266960bb713cfda7293f1fe3ffa2d4 Mon Sep 17 00:00:00 2001 From: wangdl Date: Thu, 11 Jun 2026 21:19:42 +0800 Subject: [PATCH] docs: iOS ReadingEvent V2 integration example (DOC-FULL-008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift pseudocode showing full V2 event pipeline: openSession → heartbeat → position → markedAsRead → closeSession → export → local queue → ack → error handling → crash recovery Co-Authored-By: Claude Opus 4.7 --- docs/ios-reading-event-v2-integration.md | 256 +++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/ios-reading-event-v2-integration.md diff --git a/docs/ios-reading-event-v2-integration.md b/docs/ios-reading-event-v2-integration.md new file mode 100644 index 0000000..c26124d --- /dev/null +++ b/docs/ios-reading-event-v2-integration.md @@ -0,0 +1,256 @@ +# 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) + → set_session_closed_at_ms_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 + try close_reading_session_v2(sessionId: sessionId) + // 3. Set closed time + try set_session_closed_at_ms_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) + } + + do { + try close_reading_session_v2(sessionId: sid) + } catch let error as SessionError { + if error == .alreadyClosed { + os_log("Session already closed, ignoring") + return + } + os_log("Close failed: %@", error.localizedDescription) + } + + let ts = Int64(Date().timeIntervalSince1970 * 1000) + try? set_session_closed_at_ms_v2(sessionId: sid, timestampMs: ts) +} +``` + +## 异常恢复示例 + +```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 接入