# 端到端教程·iOS 篇

> 从零创建一个 SwiftUI 应用,完整走通:从你的后端换取用户令牌、绑定 S1 录音卡、同步录音、经你的后端上传转写,最后把逐字稿渲染出来。

开发者平台处于内测阶段,接口契约以联调时提供的正式文档为准。账号由对接工程师开通,详见[联系我们](/docs/mcp-cli/contact/)。

本篇从一个空白的 SwiftUI 工程开始,一步步搭出一条完整链路:App 从你自己的后端拿到用户令牌,配置 Embedded SDK,扫描并绑定 S1 录音卡,把设备上的录音同步到手机,再交给你的后端上传转写,最终在 App 里展示带说话人与时间戳的逐字稿。服务端部分(签发令牌、代理上传与转写)在[端到端教程·后端篇](/docs/embedded/tutorial-backend/),两篇配套使用。

  图示占位端到端数据流示意(S1 录音卡 → 你的 App → 你的后端 → iKho 开发者平台)

先记住一条边界:合作方机密(`client_secret` 与 `api_key`)只存在于你的后端,绝不进 App。App 只持后端签发的短时效用户令牌,用它做两件设计内的事:绑定设备,以及按预签名地址直传音频到 iKho 存储;提交转写与轮询由你的后端用机密凭证完成。后文每一步都遵守这条边界,与[后端篇](/docs/embedded/tutorial-backend/)的接口一一对应。

## 前置条件

| 项目 | 要求 |
|---|---|
| 开发机 | macOS + Xcode 15 及以上,Swift 5.9 及以上 |
| 测试设备 | 一部真机 iPhone(iOS 15 及以上)。SDK 以 arm64 真机框架分发,模拟器不支持设备联调 |
| 硬件 | 一张 iKho S1 录音卡(硬件规格以量产规格为准) |
| 服务端 | 一个按[后端篇](/docs/embedded/tutorial-backend/)搭好的后端,至少提供签发用户令牌与代理转写两组接口 |
| 凭证 | SDK 依赖地址与合作方凭证由对接工程师随开通邮件发放 |

## 创建工程并安装 SDK

  
    1

    新建 SwiftUI 工程
在 Xcode 里选 `App` 模板,Interface 选 `SwiftUI`,Language 选 `Swift`。本文工程名用 `S1Tutorial`,你可以换成自己的。

  

  
    2

    添加 SDK 依赖
SDK 包名为 `IKhoEmbedded`,依赖地址随开通邮件发放。用 Swift Package Manager 添加:

  swift
    
  

  
```
// Package.swift 或 Xcode「Package Dependencies」面板
// 依赖地址随开通邮件发放
dependencies: [
    .package(url: "https://<你的内测 Git 地址>/ikho-embedded-ios.git", exact: "0.9.0"),  // 内测期锁精确版本,升级时对照更新日志
]
```

CocoaPods 安装方式见 [iOS SDK](/docs/embedded/ios-sdk/) 的「安装」一节。

  

  
    3

    声明权限
SDK 通过蓝牙与 S1 通信,并在应用内提供录制与试听,需在 `Info.plist` 声明以下条目,否则系统会拒绝相关能力。

  Info.plist
    
  

  
```
<key>NSBluetoothAlwaysUsageDescription</key>
<string>用于连接 iKho 录音卡并同步设备上的录音</string>

<key>NSMicrophoneUsageDescription</key>
<string>用于在应用内录制与试听音频</string>

<!-- 后台保持蓝牙连接,支持后台同步 -->
<key>UIBackgroundModes</key>
<array>
  <string>bluetooth-central</string>
</array>
```

  

## 第 1 步:从你的后端获取用户令牌

用户令牌由你的后端调用[获取用户令牌](/docs/api-reference/auth/get-user-token/)接口签发,再经你后端自己的 `POST /token` 接口下发给 App(实现见[后端篇](/docs/embedded/tutorial-backend/))。App 用你应用自己的登录态调用这个接口,后端据此识别用户并透传平台返回的令牌。

  TokenClient.swift
    
  

  
```
import Foundation

enum AppConfig {
    /// 你自己后端的地址,换成真实域名
    static let backendBaseURL = URL(string: "https://your-backend.example.com")!
    /// 你应用自己的登录态(示例用静态值,正式应用接入你自己的账号体系)
    static var sessionToken = ""
    /// 你系统里的用户标识,与后端篇 /token、/recordings/complete 的 user_id 一致
    static var userId = ""
    /// 后端签发的用户令牌,第 2 步配置 SDK 时顺手保存,第 5 步直传文件也要用
    static var userAccessToken = ""
}

/// 你后端 POST /token 的响应,字段与后端篇保持一致
struct TokenResponse: Codable {
    let accessToken: String
    let tokenType: String
    let expiresIn: Int
}

final class TokenClient {
    private let session = URLSession(configuration: .default)

    /// 向你自己的后端换取用户令牌
    func fetchUserToken() async throws -> TokenResponse {
        var request = URLRequest(url: AppConfig.backendBaseURL.appendingPathComponent("token"))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        // 用你应用自己的登录态做鉴权,后端据此识别是哪位用户
        request.setValue("Bearer " + AppConfig.sessionToken, forHTTPHeaderField: "Authorization")
        request.httpBody = try JSONEncoder().encode(["user_id": AppConfig.userId])

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try decoder.decode(TokenResponse.self, from: data)
    }
}
```

  POST /token 响应示例(你的后端)
    
  

  
```
{
  "access_token": "eyJhbGci...",
  "token_type": "bearer",
  "expires_in": 86400
}
```

合作方 `client_id`、`secret` 与合作方令牌只能存在于你的后端,任何一项都不要写进 App。App 里唯一出现的凭证是这枚有有效期的用户令牌。

## 第 2 步:配置 SDK

拿到用户令牌后,在应用启动流程里调用一次 `IKhoEmbedded.configure`。下面是 App 入口与根视图:先异步取令牌并配置 SDK,再按设备状态切换到配对或录音列表界面(两个界面在后面两步实现)。

  S1TutorialApp.swift
    
  

  
```
import SwiftUI
import IKhoEmbedded

@main
struct S1TutorialApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

struct ContentView: View {
    @StateObject private var store = DeviceStore()
    @State private var configured = false
    @State private var failed = false

    var body: some View {
        NavigationStack {
            if failed {
                VStack(spacing: 12) {
                    Text("初始化失败,请检查网络后重试")
                    Button("重试") {
                        failed = false
                        Task { await configureSDK() }
                    }
                }
            } else if !configured {
                ProgressView("正在准备")
                    .task { await configureSDK() }
            } else if store.phase != .ready {
                PairingView(store: store)          // 见「第 3 步:扫描并绑定 S1」
            } else {
                RecordingListView(store: store)    // 见「第 4 步:同步设备上的录音」
            }
        }
    }

    private func configureSDK() async {
        do {
            let token = try await TokenClient().fetchUserToken()
            AppConfig.userAccessToken = token.accessToken   // 第 5 步直传文件也要用
            IKhoEmbedded.configure(userToken: token.accessToken)
            configured = true
        } catch {
            failed = true
        }
    }
}
```

### 令牌过期怎么办

用户令牌有有效期(上例为 86400 秒)。收到 `IKHO_ERR_UNAUTHORIZED` 或用户重新登录后,向后端重新换一枚令牌并更新给 SDK 即可,无需重新配置。

  swift
    
  

  
```
let token = try await TokenClient().fetchUserToken()
IKhoEmbedded.setUserToken(token.accessToken)
```

## 第 3 步:扫描并绑定 S1

设备的所有事件都通过 `IKhoDeviceDelegate` 回调返回。我们用一个 `ObservableObject` 承接回调、维护界面状态;回调默认在 SDK 内部队列触发,更新已发布属性前统一切回主线程。

  DeviceStore.swift
    
  

  
```
import Foundation
import IKhoEmbedded

final class DeviceStore: ObservableObject {

    enum Phase: Equatable {
        case idle       // 未绑定
        case scanning   // 扫描中
        case binding    // 绑定中
        case ready      // 已绑定,可同步
    }

    @Published var phase: Phase = .idle
    @Published var discovered: [IKhoDevice] = []
    @Published var batteryLevel = 0
    @Published var isCharging = false
    @Published var recordings: [IKhoRecording] = []
    @Published var syncProgress: [String: Int] = [:]   // recordingId → 0–100
    @Published var localFiles: [String: String] = [:]  // recordingId → 本地文件路径
    @Published var lastError: String?

    init() {
        IKhoDeviceManager.shared.delegate = self
    }

    func startScan() {
        phase = .scanning
        discovered = []
        IKhoDeviceManager.shared.scanDevices()
    }

    func bind(_ device: IKhoDevice) {
        phase = .binding
        IKhoDeviceManager.shared.stopScan()
        IKhoDeviceManager.shared.bind(device: device)
    }

    func refreshRecordings() {
        IKhoDeviceManager.shared.fetchRecordings()
    }

    func sync(_ recording: IKhoRecording) {
        syncProgress[recording.id] = 0
        IKhoDeviceManager.shared.exportAudio(recordingId: recording.id, format: .m4a)
    }
}

// 连接与状态回调
extension DeviceStore: IKhoDeviceDelegate {

    func deviceScanResult(_ devices: [IKhoDevice]) {
        DispatchQueue.main.async {
            self.discovered = devices
        }
    }

    func deviceBindChanged(_ result: IKhoBindResult) {
        DispatchQueue.main.async {
            if result.bound {
                self.phase = .ready
                IKhoDeviceManager.shared.fetchRecordings()
            } else {
                self.phase = .idle
            }
        }
    }

    // 唯一必须实现的回调:握手完成后返回设备总体状态
    func deviceStateChanged(_ state: IKhoDeviceState) {
        DispatchQueue.main.async {
            self.batteryLevel = state.batteryLevel
            self.isCharging = state.isCharging
        }
    }

    func deviceBatteryChanged(level: Int, isCharging: Bool) {
        DispatchQueue.main.async {
            self.batteryLevel = level
            self.isCharging = isCharging
        }
    }

    func didEncounterError(_ error: IKhoError) {
        DispatchQueue.main.async {
            self.lastError = error.localizedDescription
        }
    }
}
```

配对界面把扫描结果列出来,点某台设备发起绑定。扫描由用户点按钮触发,不在启动时自动开始,原因见「第 7 步:真机调试注意」。

  PairingView.swift
    
  

  
```
import SwiftUI
import IKhoEmbedded

struct PairingView: View {
    @ObservedObject var store: DeviceStore

    var body: some View {
        List {
            Section("附近的 S1") {
                ForEach(store.discovered, id: \.serialNumber) { device in
                    Button {
                        store.bind(device)
                    } label: {
                        HStack {
                            Text(device.serialNumber)
                            Spacer()
                            if store.phase == .binding {
                                ProgressView()
                            }
                        }
                    }
                }
                if store.discovered.isEmpty {
                    Text(store.phase == .scanning ? "正在扫描附近设备" : "点右上角「开始扫描」查找设备")
                        .foregroundStyle(.secondary)
                }
            }
        }
        .navigationTitle("绑定 S1")
        .toolbar {
            Button("开始扫描") {
                store.startScan()
            }
        }
    }
}
```

一台 S1 同一时间只能绑定一个应用(用于文件加密与安全隔离)。测试结束、卸载应用前记得调用 `IKhoDeviceManager.shared.unbind(clear: true)` 解绑,否则设备无法再绑定到其他应用。

## 第 4 步:同步设备上的录音

绑定成功后,`DeviceStore` 已经调用了 `fetchRecordings()`(默认走 BLE 通道),结果通过 `recordingListUpdated(_:)` 返回。点某条录音的「同步」调用 `exportAudio`,进度与结果继续走代理回调。给 `DeviceStore` 补上这组回调:

  DeviceStore+Sync.swift
    
  

  
```
// 录音列表与同步回调
extension DeviceStore {

    func recordingListUpdated(_ recordings: [IKhoRecording]) {
        DispatchQueue.main.async {
            self.recordings = recordings
        }
    }

    func exportProgress(recordingId: String, progress: Int) {
        DispatchQueue.main.async {
            self.syncProgress[recordingId] = progress
        }
    }

    func exportCompleted(recordingId: String, outputPath: String) {
        DispatchQueue.main.async {
            self.syncProgress[recordingId] = nil
            self.localFiles[recordingId] = outputPath
        }
    }

    func exportFailed(recordingId: String, error: IKhoError) {
        DispatchQueue.main.async {
            self.syncProgress[recordingId] = nil
            self.lastError = "同步失败:" + error.localizedDescription
        }
    }
}
```

  RecordingListView.swift
    
  

  
```
import SwiftUI
import IKhoEmbedded

struct RecordingListView: View {
    @ObservedObject var store: DeviceStore

    var body: some View {
        List(store.recordings, id: \.id) { recording in
            VStack(alignment: .leading, spacing: 6) {
                HStack {
                    Text(Self.duration(recording.durationSeconds))
                        .font(.body.monospacedDigit())
                    Spacer()
                    if let progress = store.syncProgress[recording.id] {
                        Text("\(progress)%")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    } else if store.localFiles[recording.id] != nil {
                        Text("已同步")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    } else {
                        Button("同步") {
                            store.sync(recording)
                        }
                    }
                }
                if let progress = store.syncProgress[recording.id] {
                    ProgressView(value: Double(progress), total: 100)
                }
            }
            .padding(.vertical, 4)
        }
        .navigationTitle("设备上的录音")
        .refreshable {
            store.refreshRecordings()
        }
    }

    static func duration(_ seconds: Int) -> String {
        String(format: "%02d:%02d", seconds / 60, seconds % 60)
    }
}
```

BLE 通道适合日常单条同步;大批量或大文件建议开启 Wi-Fi 快传(`setWiFiTransfer(enabled: true)`),吞吐显著高于 BLE,用法见 [iOS SDK](/docs/embedded/ios-sdk/) 的「Wi-Fi 快传」一节。同步中断支持断点续传,重新发起即可。

## 第 5 步:直传音频,回报你的后端

录音同步到手机后,App 用用户令牌走[文件上传 API](/docs/api-reference/file/overview/)把音频直传 iKho 存储:申请预签名地址、按分片 PUT 字节、合并分片拿到 `DownloadUrl`;然后把 `DownloadUrl` 回报给你后端的 `/recordings/complete`(实现见[后端篇](/docs/embedded/tutorial-backend/)),由后端提交转写,App 随后向后端轮询结果。

分工与安全:音频字节由 App 直传存储,你的后端不经手文件;提交转写与轮询用的机密凭证只在后端。App 里只有短时效用户令牌与你自己的登录态,凭证不落端侧,后端仍掌握配额、审计与内容策略。

  BackendClient.swift
    
  

  
```
import Foundation

enum BackendError: Error {
    case badResponse
    case transcriptionFailed
}

/// 预签名上传计划(字段名与文件上传 API 一致,PascalCase 用显式 CodingKeys)
struct UploadPlan: Decodable {
    let fileId: String
    let uploadId: String
    let chunkSize: Int
    let parts: [Part]

    struct Part: Decodable {
        let partNumber: Int
        let presignedUrl: String
        enum CodingKeys: String, CodingKey {
            case partNumber = "PartNumber", presignedUrl = "PresignedUrl"
        }
    }
    enum CodingKeys: String, CodingKey {
        case fileId = "FileId", uploadId = "UploadId", chunkSize = "ChunkSize", parts = "Parts"
    }
}

/// 合并分片的响应
struct CompleteUploadResponse: Decodable {
    let downloadUrl: String
    enum CodingKeys: String, CodingKey { case downloadUrl = "DownloadUrl" }
}

/// 你后端 /recordings/complete 的响应,字段与后端篇一致
struct CompleteResponse: Codable {
    let transcriptionId: String
    let status: String
}

struct TranscriptSegment: Codable, Identifiable {
    let speaker: String?
    let startMs: Int
    let endMs: Int
    let text: String
    var id: Int { startMs }
}

/// 你后端逐字稿接口的响应;status 透传平台任务状态
struct TranscriptResponse: Codable {
    let status: String
    let segments: [TranscriptSegment]?
}

/// 用用户令牌把音频直传 iKho 存储:预签名 → 分片 PUT → 合并,拿到 DownloadUrl
final class IKhoFileClient {
    private let host = URL(string: "https://platform.ikho.cn/developer/api")!
    private let session = URLSession(configuration: .default)

    func upload(fileURL: URL) async throws -> String {
        let audio = try Data(contentsOf: fileURL)
        let filetype = fileURL.pathExtension.lowercased()   // m4a / mp3 / wav

        // 1. 申请预签名上传计划
        let plan: UploadPlan = try await postJSON(
            path: "/open/partner/files/upload/generate-presigned-urls",
            body: ["filesize": audio.count, "filetype": filetype]
        )

        // 2. 按 ChunkSize 切分,逐片 PUT 到预签名地址,收集 ETag
        var partList: [[String: Any]] = []
        for part in plan.parts {
            let start = (part.partNumber - 1) * plan.chunkSize
            let end = min(start + plan.chunkSize, audio.count)
            var put = URLRequest(url: URL(string: part.presignedUrl)!)
            put.httpMethod = "PUT"
            let (_, resp) = try await session.upload(for: put, from: audio[start..<end])
            guard let http = resp as? HTTPURLResponse, http.statusCode == 200,
                  let etag = http.value(forHTTPHeaderField: "ETag") else {
                throw BackendError.badResponse
            }
            partList.append(["part_number": part.partNumber, "etag": etag])
        }

        // 3. 合并分片,拿到可下载地址
        let done: CompleteUploadResponse = try await postJSON(
            path: "/open/partner/files/upload/complete-upload",
            body: ["file_id": plan.fileId, "upload_id": plan.uploadId,
                   "part_list": partList, "filetype": filetype]
        )
        return done.downloadUrl
    }

    private func postJSON<T: Decodable>(path: String, body: [String: Any]) async throws -> T {
        var request = URLRequest(url: host.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.setValue("Bearer " + AppConfig.userAccessToken, forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw BackendError.badResponse
        }
        return try JSONDecoder().decode(T.self, from: data)
    }
}

final class BackendClient {
    private let session = URLSession(configuration: .default)
    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        return d
    }()

    /// 把 DownloadUrl 回报给你自己的后端,由后端提交转写
    func reportComplete(fileUrl: String) async throws -> CompleteResponse {
        var request = URLRequest(url: AppConfig.backendBaseURL
            .appendingPathComponent("recordings")
            .appendingPathComponent("complete"))
        request.httpMethod = "POST"
        request.setValue("Bearer " + AppConfig.sessionToken, forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode([
            "user_id": AppConfig.userId,
            "file_url": fileUrl,
        ])

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse, (200...202).contains(http.statusCode) else {
            throw BackendError.badResponse
        }
        return try decoder.decode(CompleteResponse.self, from: data)
    }

    /// 轮询你后端的逐字稿接口,SUCCESS 后返回 segments
    func waitForTranscript(transcriptionId: String) async throws -> [TranscriptSegment] {
        let url = AppConfig.backendBaseURL
            .appendingPathComponent("recordings")
            .appendingPathComponent(transcriptionId)
            .appendingPathComponent("transcript")
        var request = URLRequest(url: url)
        request.setValue("Bearer " + AppConfig.sessionToken, forHTTPHeaderField: "Authorization")

        while true {
            let (data, response) = try await session.data(for: request)
            guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
                throw BackendError.badResponse
            }
            let result = try decoder.decode(TranscriptResponse.self, from: data)
            switch result.status {
            case "SUCCESS":
                return result.segments ?? []
            case "FAILURE", "REVOKED":
                throw BackendError.transcriptionFailed
            default:
                // PENDING / RECEIVED / STARTED / PROGRESS:间隔 3 秒继续轮询
                try await Task.sleep(nanoseconds: 3_000_000_000)
            }
        }
    }
}
```

  GET /recordings/{id}/transcript 响应示例(你的后端)
    
  

  
```
{
  "status": "SUCCESS",
  "segments": [
    { "speaker": "发言人 1", "start_ms": 0, "end_ms": 4600, "text": "王先生您好,上次复诊后睡眠情况怎么样。" },
    { "speaker": "发言人 2", "start_ms": 4600, "end_ms": 9200, "text": "比之前好一些,不过后半夜还是容易醒。" },
    { "speaker": "发言人 1", "start_ms": 9200, "end_ms": 14100, "text": "好的,我把剂量再微调一下,两周后我们再看一次。" }
  ]
}
```

`segments` 的字段口径与平台[查询转写任务](/docs/api-reference/transcription/get-task/)一致(`speaker` / `start_ms` / `end_ms` / `text`),后端原样透传即可;任务状态取值也一致。转写模型在后端提交时选择:`ikho-asr-pro`(高精度)或 `ikho-asr-fast`(低时延)。

## 第 6 步:展示逐字稿

最后一段界面:进入逐字稿页时自动完成「上传 → 轮询 → 渲染」,segments 用 SwiftUI `List` 逐条展示说话人、时间戳与文本。

  TranscriptScreen.swift
    
  

  
```
import SwiftUI

struct TranscriptScreen: View {
    /// 已同步到本地的音频文件路径(exportCompleted 返回的 outputPath)
    let localPath: String

    @State private var segments: [TranscriptSegment] = []
    @State private var stateText = "正在上传录音"
    @State private var failed = false

    var body: some View {
        Group {
            if failed {
                Text("转写失败,请返回重试")
                    .foregroundStyle(.secondary)
            } else if segments.isEmpty {
                ProgressView(stateText)
            } else {
                TranscriptView(segments: segments)
            }
        }
        .task { await load() }
        .navigationTitle("逐字稿")
    }

    private func load() async {
        do {
            // 1. 用用户令牌把音频直传 iKho 存储,拿到 DownloadUrl
            let downloadUrl = try await IKhoFileClient()
                .upload(fileURL: URL(fileURLWithPath: localPath))
            // 2. 回报你的后端,由后端提交转写
            let backend = BackendClient()
            let created = try await backend.reportComplete(fileUrl: downloadUrl)
            stateText = "转写中,请稍候"
            // 3. 轮询你的后端直到 SUCCESS
            segments = try await backend.waitForTranscript(transcriptionId: created.transcriptionId)
        } catch {
            failed = true
        }
    }
}

struct TranscriptView: View {
    let segments: [TranscriptSegment]

    var body: some View {
        List(segments) { segment in
            VStack(alignment: .leading, spacing: 4) {
                HStack(spacing: 8) {
                    Text(segment.speaker ?? "发言人")
                        .font(.subheadline.weight(.semibold))
                    Text(Self.timestamp(ms: segment.startMs))
                        .font(.caption.monospacedDigit())
                        .foregroundStyle(.secondary)
                }
                Text(segment.text)
                    .font(.body)
            }
            .padding(.vertical, 4)
        }
        .listStyle(.plain)
    }

    static func timestamp(ms: Int) -> String {
        let total = ms / 1000
        return String(format: "%02d:%02d", total / 60, total % 60)
    }
}
```

在录音列表里给已同步的条目加一个入口,替换第 4 步里的「已同步」标签:

  swift
    
  

  
```
// RecordingListView 中,把「已同步」标签换成逐字稿入口
if let path = store.localFiles[recording.id] {
    NavigationLink("查看逐字稿") {
        TranscriptScreen(localPath: path)
    }
    .font(.caption)
}
```

到这里链路已经闭环:真机运行,绑定 S1,选一条录音同步,点「查看逐字稿」,稍候即可看到带说话人与时间戳的逐字稿。

走完自查:App 里没有出现合作方 `client_id` / `secret`;所有网络请求都指向你自己的后端;用户令牌来自后端 `POST /token`;转写状态与 `segments` 字段和平台口径一致。

## 第 7 步:真机调试注意

### 蓝牙权限弹窗的时机

系统的蓝牙授权弹窗在应用第一次使用蓝牙时出现,对应第一次调用 `scanDevices()`。所以把扫描放在用户点了「开始扫描」之后,不要在启动时自动扫:用户在自己发起的动作里看到弹窗,授权率高得多。若用户拒绝授权,之后的扫描会收到 `IKHO_ERR_BLE_UNAVAILABLE`,此时引导用户去系统设置开启蓝牙权限,不要反复重试。

`Info.plist` 缺少 `NSBluetoothAlwaysUsageDescription` 时,应用会在首次触发蓝牙时直接被系统终止。真机联调前先核对权限声明是否齐全。

### 后台与传输

  
- 已声明 `UIBackgroundModes` 的 `bluetooth-central` 后,BLE 同步在退到后台时可以继续,但系统会按自身策略调度,长文件建议引导用户停留在前台,或依赖断点续传分段完成。
  
- Wi-Fi 快传期间手机会临时接入 S1 的热点,普通网络可能短暂中断;传输中的上传请求建议等快传结束再发起。
  
- 本文上传用前台 `async` 请求以保持简单;正式应用中大文件上传建议改用 background `URLSessionConfiguration`,退后台不中断。

### 其他常见问题

| 现象 | 原因与处理 |
|---|---|
| 模拟器上找不到设备 | SDK 为 arm64 真机框架,模拟器不支持设备联调,必须用真机 iPhone |
| 首次真机运行报签名错误 | 在 Signing 面板选择你的开发者团队,并在 iPhone 的「设置 → 通用 → VPN 与设备管理」里信任开发者证书 |
| 扫描超时(`IKHO_ERR_DEVICE_NOT_FOUND`) | 确认 S1 已开机且在近场,重试扫描 |
| 绑定被占用(`IKHO_ERR_BIND_OCCUPIED`) | 设备已绑定到其他应用,先在原应用解绑再重试 |
| 接口返回 401(`IKHO_ERR_UNAUTHORIZED`) | 用户令牌过期,向你的后端重新换取并调用 `setUserToken` |

## 下一步

[端到端教程·后端篇 本篇的另一半:签发用户令牌、代理文件上传与提交转写的服务端实现。 打开后端篇](/docs/embedded/tutorial-backend/)

[iOS SDK 参考 完整方法与回调:Wi-Fi 快传、批量导出、解绑与全部错误码。 打开 iOS SDK](/docs/embedded/ios-sdk/)
