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

253 lines
8.4 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 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 }
// 转为 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)
}
}
```
## 错误处理示例
```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 接入