docs: iOS ReadingEvent V2 integration example (DOC-FULL-008)
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 <noreply@anthropic.com>
This commit is contained in:
parent
b08fe28525
commit
e73575205c
256
docs/ios-reading-event-v2-integration.md
Normal file
256
docs/ios-reading-event-v2-integration.md
Normal file
@ -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 接入
|
||||||
Loading…
x
Reference in New Issue
Block a user