端到端教程·iOS 篇
从零创建一个 SwiftUI 应用,完整走通:从你的后端换取用户令牌、绑定 S1 录音卡、同步录音、经你的后端上传转写,最后把逐字稿渲染出来。
开发者平台处于内测阶段,接口契约以联调时提供的正式文档为准。账号由对接工程师开通,详见联系我们。
本篇从一个空白的 SwiftUI 工程开始,一步步搭出一条完整链路:App 从你自己的后端拿到用户令牌,配置 Embedded SDK,扫描并绑定 S1 录音卡,把设备上的录音同步到手机,再交给你的后端上传转写,最终在 App 里展示带说话人与时间戳的逐字稿。服务端部分(签发令牌、代理上传与转写)在端到端教程·后端篇,两篇配套使用。
先记住一条边界:合作方机密(client_secret 与 api_key)只存在于你的后端,绝不进 App。App 只持后端签发的短时效用户令牌,用它做两件设计内的事:绑定设备,以及按预签名地址直传音频到 iKho 存储;提交转写与轮询由你的后端用机密凭证完成。后文每一步都遵守这条边界,与后端篇的接口一一对应。
前置条件
| 项目 | 要求 |
|---|---|
| 开发机 | macOS + Xcode 15 及以上,Swift 5.9 及以上 |
| 测试设备 | 一部真机 iPhone(iOS 15 及以上)。SDK 以 arm64 真机框架分发,模拟器不支持设备联调 |
| 硬件 | 一张 iKho S1 录音卡(硬件规格以量产规格为准) |
| 服务端 | 一个按后端篇搭好的后端,至少提供签发用户令牌与代理转写两组接口 |
| 凭证 | SDK 依赖地址与合作方凭证由对接工程师随开通邮件发放 |
创建工程并安装 SDK
App 模板,Interface 选 SwiftUI,Language 选 Swift。本文工程名用 S1Tutorial,你可以换成自己的。IKhoEmbedded,依赖地址随开通邮件发放。用 Swift Package Manager 添加:
// Package.swift 或 Xcode「Package Dependencies」面板
// 依赖地址随开通邮件发放
dependencies: [
.package(url: "https://<你的内测 Git 地址>/ikho-embedded-ios.git", exact: "0.9.0"), // 内测期锁精确版本,升级时对照更新日志
]
CocoaPods 安装方式见 iOS SDK 的「安装」一节。
Info.plist 声明以下条目,否则系统会拒绝相关能力。
<key>NSBluetoothAlwaysUsageDescription</key>
<string>用于连接 iKho 录音卡并同步设备上的录音</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于在应用内录制与试听音频</string>
<!-- 后台保持蓝牙连接,支持后台同步 -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
第 1 步:从你的后端获取用户令牌
用户令牌由你的后端调用获取用户令牌接口签发,再经你后端自己的 POST /token 接口下发给 App(实现见后端篇)。App 用你应用自己的登录态调用这个接口,后端据此识别用户并透传平台返回的令牌。
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)
}
}
{
"access_token": "eyJhbGci...",
"token_type": "bearer",
"expires_in": 86400
}
合作方 client_id、secret 与合作方令牌只能存在于你的后端,任何一项都不要写进 App。App 里唯一出现的凭证是这枚有有效期的用户令牌。
第 2 步:配置 SDK
拿到用户令牌后,在应用启动流程里调用一次 IKhoEmbedded.configure。下面是 App 入口与根视图:先异步取令牌并配置 SDK,再按设备状态切换到配对或录音列表界面(两个界面在后面两步实现)。
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 即可,无需重新配置。
let token = try await TokenClient().fetchUserToken()
IKhoEmbedded.setUserToken(token.accessToken)
第 3 步:扫描并绑定 S1
设备的所有事件都通过 IKhoDeviceDelegate 回调返回。我们用一个 ObservableObject 承接回调、维护界面状态;回调默认在 SDK 内部队列触发,更新已发布属性前统一切回主线程。
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 步:真机调试注意」。
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 补上这组回调:
// 录音列表与同步回调
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
}
}
}
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 的「Wi-Fi 快传」一节。同步中断支持断点续传,重新发起即可。
第 5 步:直传音频,回报你的后端
录音同步到手机后,App 用用户令牌走文件上传 API把音频直传 iKho 存储:申请预签名地址、按分片 PUT 字节、合并分片拿到 DownloadUrl;然后把 DownloadUrl 回报给你后端的 /recordings/complete(实现见后端篇),由后端提交转写,App 随后向后端轮询结果。
分工与安全:音频字节由 App 直传存储,你的后端不经手文件;提交转写与轮询用的机密凭证只在后端。App 里只有短时效用户令牌与你自己的登录态,凭证不落端侧,后端仍掌握配额、审计与内容策略。
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)
}
}
}
}
{
"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 的字段口径与平台查询转写任务一致(speaker / start_ms / end_ms / text),后端原样透传即可;任务状态取值也一致。转写模型在后端提交时选择:ikho-asr-pro(高精度)或 ikho-asr-fast(低时延)。
第 6 步:展示逐字稿
最后一段界面:进入逐字稿页时自动完成「上传 → 轮询 → 渲染」,segments 用 SwiftUI List 逐条展示说话人、时间戳与文本。
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 步里的「已同步」标签:
// 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请求以保持简单;正式应用中大文件上传建议改用 backgroundURLSessionConfiguration,退后台不中断。
其他常见问题
| 现象 | 原因与处理 |
|---|---|
| 模拟器上找不到设备 | SDK 为 arm64 真机框架,模拟器不支持设备联调,必须用真机 iPhone |
| 首次真机运行报签名错误 | 在 Signing 面板选择你的开发者团队,并在 iPhone 的「设置 → 通用 → VPN 与设备管理」里信任开发者证书 |
扫描超时(IKHO_ERR_DEVICE_NOT_FOUND) | 确认 S1 已开机且在近场,重试扫描 |
绑定被占用(IKHO_ERR_BIND_OCCUPIED) | 设备已绑定到其他应用,先在原应用解绑再重试 |
接口返回 401(IKHO_ERR_UNAUTHORIZED) | 用户令牌过期,向你的后端重新换取并调用 setUserToken |
下一步
本篇的另一半:签发用户令牌、代理文件上传与提交转写的服务端实现。
完整方法与回调:Wi-Fi 快传、批量导出、解绑与全部错误码。