Skip to content

iOS医疗应用开发

学习目标

通过本文档的学习,你将能够:

  • 理解核心概念和原理
  • 掌握实际应用方法
  • 了解最佳实践和注意事项

前置知识

在学习本文档之前,建议你已经掌握:

  • 基础的嵌入式系统知识
  • C/C++编程基础
  • 相关领域的基本概念

概述

iOS平台因其严格的安全标准、统一的硬件生态和高质量的用户体验,成为医疗应用开发的首选平台之一。本指南涵盖iOS医疗应用开发的关键技术、最佳实践和监管要求。

开发环境设置

必需工具

  • Xcode: Apple官方IDE(最新稳定版本)
  • macOS: 运行Xcode的必需操作系统
  • Apple Developer Account: 用于真机测试和应用发布
  • CocoaPods/Swift Package Manager: 依赖管理工具

推荐工具

  • Instruments: 性能分析工具
  • TestFlight: Beta测试平台
  • Fastlane: 自动化部署工具
  • SwiftLint: 代码规范检查

编程语言选择

Swift(推荐)

// 现代、安全、高性能
import UIKit
import HealthKit

class HealthDataManager {
    let healthStore = HKHealthStore()

    func requestAuthorization() async throws {
        let types: Set<HKSampleType> = [
            HKObjectType.quantityType(forIdentifier: .heartRate)!,
            HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic)!
        ]

        try await healthStore.requestAuthorization(toShare: [], read: types)
    }
}

优势: - 类型安全,减少运行时错误 - 现代语法,提高开发效率 - Apple官方支持,持续更新 - 优秀的性能表现

Objective-C(遗留项目)

// 用于维护旧代码或与旧库集成
@interface HealthDataManager : NSObject
@property (nonatomic, strong) HKHealthStore *healthStore;
- (void)requestAuthorizationWithCompletion:(void(^)(BOOL success, NSError *error))completion;
@end

核心框架

1. HealthKit

HealthKit是Apple提供的健康数据框架,允许应用读取和写入健康数据。

配置HealthKit

// 1. 在Xcode中启用HealthKit capability
// 2. 在Info.plist中添加隐私说明
<!-- Info.plist -->
<key>NSHealthShareUsageDescription</key>
<string>我们需要访问您的健康数据以提供个性化健康建议</string>
<key>NSHealthUpdateUsageDescription</key>
<string>我们需要更新您的健康数据以记录您的健康活动</string>

读取健康数据

import HealthKit

class HealthKitManager {
    let healthStore = HKHealthStore()

    // 检查HealthKit可用性
    func isHealthKitAvailable() -> Bool {
        return HKHealthStore.isHealthDataAvailable()
    }

    // 请求授权
    func requestAuthorization() async throws {
        guard isHealthKitAvailable() else {
            throw HealthKitError.notAvailable
        }

        let readTypes: Set<HKObjectType> = [
            HKObjectType.quantityType(forIdentifier: .heartRate)!,
            HKObjectType.quantityType(forIdentifier: .stepCount)!,
            HKObjectType.quantityType(forIdentifier: .bloodGlucose)!,
            HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
        ]

        let writeTypes: Set<HKSampleType> = [
            HKObjectType.quantityType(forIdentifier: .bodyMass)!,
            HKObjectType.quantityType(forIdentifier: .bloodPressureSystolic)!
        ]

        try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes)
    }

    // 查询心率数据
    func fetchHeartRateData(completion: @escaping ([HKQuantitySample]?, Error?) -> Void) {
        guard let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
            completion(nil, HealthKitError.invalidType)
            return
        }

        let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
        let query = HKSampleQuery(sampleType: heartRateType,
                                   predicate: nil,
                                   limit: 100,
                                   sortDescriptors: [sortDescriptor]) { query, samples, error in
            guard let samples = samples as? [HKQuantitySample] else {
                completion(nil, error)
                return
            }
            completion(samples, nil)
        }

        healthStore.execute(query)
    }

    // 写入体重数据
    func saveWeight(_ weight: Double, date: Date = Date()) async throws {
        guard let weightType = HKQuantityType.quantityType(forIdentifier: .bodyMass) else {
            throw HealthKitError.invalidType
        }

        let weightQuantity = HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: weight)
        let weightSample = HKQuantitySample(type: weightType,
                                            quantity: weightQuantity,
                                            start: date,
                                            end: date)

        try await healthStore.save(weightSample)
    }

    // 观察实时数据变化
    func observeHeartRate(handler: @escaping (HKQuantitySample) -> Void) {
        guard let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) else {
            return
        }

        let query = HKObserverQuery(sampleType: heartRateType, predicate: nil) { query, completionHandler, error in
            if error != nil {
                completionHandler()
                return
            }

            // 获取最新数据
            self.fetchHeartRateData { samples, error in
                if let latestSample = samples?.first {
                    handler(latestSample)
                }
                completionHandler()
            }
        }

        healthStore.execute(query)
    }
}

enum HealthKitError: Error {
    case notAvailable
    case invalidType
    case authorizationDenied
}

2. CareKit

CareKit是用于构建护理管理应用的开源框架。

import CareKit
import CareKitStore

class CareKitManager {
    let storeManager = OCKSynchronizedStoreManager(
        wrapping: OCKStore(name: "carekit-store", type: .onDisk)
    )

    // 创建护理计划
    func createCarePlan() async throws {
        let schedule = OCKSchedule.dailyAtTime(
            hour: 8, minutes: 0,
            start: Date(), end: nil,
            text: "每天早上8点"
        )

        let task = OCKTask(
            id: "medication",
            title: "服用降压药",
            carePlanUUID: nil,
            schedule: schedule
        )

        try await storeManager.store.addTask(task)
    }

    // 记录任务完成
    func completeTask(taskID: String) async throws {
        let query = OCKTaskQuery(for: Date())
        let tasks = try await storeManager.store.fetchTasks(query: query)

        guard let task = tasks.first(where: { $0.id == taskID }) else {
            throw CareKitError.taskNotFound
        }

        let outcome = OCKOutcome(
            taskUUID: task.uuid,
            taskOccurrenceIndex: 0,
            values: [OCKOutcomeValue(true)]
        )

        try await storeManager.store.addOutcome(outcome)
    }
}

3. ResearchKit

ResearchKit用于创建医学研究和临床试验应用。

import ResearchKit

class SurveyManager {
    // 创建调查问卷
    func createSurvey() -> ORKOrderedTask {
        var steps = [ORKStep]()

        // 知情同意
        let consentDocument = ORKConsentDocument()
        consentDocument.title = "研究知情同意书"

        let consentStep = ORKConsentReviewStep(
            identifier: "consent",
            signature: nil,
            in: consentDocument
        )
        steps.append(consentStep)

        // 问卷题目
        let questionStep = ORKQuestionStep(
            identifier: "pain_level",
            title: "疼痛评分",
            question: "请评估您当前的疼痛程度(0-10分)",
            answer: ORKAnswerFormat.scale(
                withMaximumValue: 10,
                minimumValue: 0,
                defaultValue: 5,
                step: 1,
                vertical: false,
                maximumValueDescription: "极度疼痛",
                minimumValueDescription: "无疼痛"
            )
        )
        steps.append(questionStep)

        // 主动任务(如步态测试)
        let walkingStep = ORKOrderedTask.shortWalk(
            withIdentifier: "walking",
            intendedUseDescription: "测试您的步态和平衡能力",
            numberOfStepsPerLeg: 20,
            restDuration: 30,
            options: []
        )

        return ORKOrderedTask(identifier: "survey", steps: steps)
    }
}

UI框架

SwiftUI(现代方法)

import SwiftUI
import HealthKit

struct HealthDashboardView: View {
    @StateObject private var viewModel = HealthViewModel()

    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 20) {
                    // 心率卡片
                    HealthMetricCard(
                        title: "心率",
                        value: viewModel.heartRate,
                        unit: "bpm",
                        icon: "heart.fill",
                        color: .red
                    )

                    // 步数卡片
                    HealthMetricCard(
                        title: "步数",
                        value: viewModel.stepCount,
                        unit: "步",
                        icon: "figure.walk",
                        color: .green
                    )

                    // 血糖卡片
                    HealthMetricCard(
                        title: "血糖",
                        value: viewModel.bloodGlucose,
                        unit: "mg/dL",
                        icon: "drop.fill",
                        color: .blue
                    )
                }
                .padding()
            }
            .navigationTitle("健康数据")
            .task {
                await viewModel.loadHealthData()
            }
        }
    }
}

struct HealthMetricCard: View {
    let title: String
    let value: Double?
    let unit: String
    let icon: String
    let color: Color

    var body: some View {
        HStack {
            Image(systemName: icon)
                .font(.system(size: 40))
                .foregroundColor(color)

            VStack(alignment: .leading) {
                Text(title)
                    .font(.headline)
                if let value = value {
                    Text("\(Int(value)) \(unit)")
                        .font(.title2)
                        .bold()
                } else {
                    Text("无数据")
                        .foregroundColor(.gray)
                }
            }

            Spacer()
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(radius: 2)
    }
}

@MainActor
class HealthViewModel: ObservableObject {
    @Published var heartRate: Double?
    @Published var stepCount: Double?
    @Published var bloodGlucose: Double?

    private let healthManager = HealthKitManager()

    func loadHealthData() async {
        do {
            try await healthManager.requestAuthorization()
            // 加载各项健康数据
            await loadHeartRate()
            await loadStepCount()
            await loadBloodGlucose()
        } catch {
            print("加载健康数据失败: \(error)")
        }
    }

    private func loadHeartRate() async {
        // 实现心率数据加载
    }

    private func loadStepCount() async {
        // 实现步数数据加载
    }

    private func loadBloodGlucose() async {
        // 实现血糖数据加载
    }
}

UIKit(传统方法)

import UIKit

class HealthViewController: UIViewController {
    private let tableView = UITableView()
    private var healthData: [HealthMetric] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        loadHealthData()
    }

    private func setupUI() {
        title = "健康数据"
        view.backgroundColor = .systemBackground

        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(HealthMetricCell.self, forCellReuseIdentifier: "cell")

        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    private func loadHealthData() {
        // 加载健康数据
    }
}

extension HealthViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return healthData.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! HealthMetricCell
        cell.configure(with: healthData[indexPath.row])
        return cell
    }
}

数据持久化

Core Data

import CoreData

class PersistenceController {
    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init() {
        container = NSPersistentContainer(name: "HealthApp")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data加载失败: \(error)")
            }
        }
    }

    func saveContext() {
        let context = container.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                print("保存失败: \(error)")
            }
        }
    }
}

UserDefaults(简单数据)

class SettingsManager {
    static let shared = SettingsManager()
    private let defaults = UserDefaults.standard

    var notificationsEnabled: Bool {
        get { defaults.bool(forKey: "notificationsEnabled") }
        set { defaults.set(newValue, forKey: "notificationsEnabled") }
    }

    var reminderTime: Date? {
        get { defaults.object(forKey: "reminderTime") as? Date }
        set { defaults.set(newValue, forKey: "reminderTime") }
    }
}

Keychain(敏感数据)

import Security

class KeychainManager {
    static let shared = KeychainManager()

    func save(key: String, data: Data) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]

        SecItemDelete(query as CFDictionary)
        return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
    }

    func load(key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]

        var result: AnyObject?
        SecItemCopyMatching(query as CFDictionary, &result)
        return result as? Data
    }

    func delete(key: String) -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]

        return SecItemDelete(query as CFDictionary) == errSecSuccess
    }
}

网络通信

URLSession

import Foundation

class APIClient {
    static let shared = APIClient()
    private let baseURL = "https://api.example.com"

    func fetchPatientData(patientID: String) async throws -> PatientData {
        guard let url = URL(string: "\(baseURL)/patients/\(patientID)") else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("Bearer \(getAuthToken())", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.serverError
        }

        return try JSONDecoder().decode(PatientData.self, from: data)
    }

    func uploadHealthData(_ data: HealthData) async throws {
        guard let url = URL(string: "\(baseURL)/health-data") else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("Bearer \(getAuthToken())", forHTTPHeaderField: "Authorization")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(data)

        let (_, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.uploadFailed
        }
    }

    private func getAuthToken() -> String {
        // 从Keychain获取认证令牌
        return ""
    }
}

enum APIError: Error {
    case invalidURL
    case serverError
    case uploadFailed
    case unauthorized
}

推送通知

本地通知

import UserNotifications

class NotificationManager {
    static let shared = NotificationManager()

    func requestAuthorization() async throws -> Bool {
        let center = UNUserNotificationCenter.current()
        return try await center.requestAuthorization(options: [.alert, .sound, .badge])
    }

    func scheduleMedicationReminder(at date: Date, medication: String) {
        let content = UNMutableNotificationContent()
        content.title = "服药提醒"
        content.body = "该服用\(medication)了"
        content.sound = .default
        content.badge = 1

        let calendar = Calendar.current
        let components = calendar.dateComponents([.hour, .minute], from: date)
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)

        let request = UNNotificationRequest(
            identifier: "medication-\(medication)",
            content: content,
            trigger: trigger
        )

        UNUserNotificationCenter.current().add(request) { error in
            if let error = error {
                print("添加通知失败: \(error)")
            }
        }
    }

    func cancelNotification(identifier: String) {
        UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
    }
}

远程推送

import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // 注册远程推送
        UNUserNotificationCenter.current().delegate = self
        application.registerForRemoteNotifications()
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("Device Token: \(token)")
        // 发送token到服务器
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("注册远程推送失败: \(error)")
    }
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.banner, .sound])
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        // 处理用户点击通知
        completionHandler()
    }
}

安全最佳实践

1. 数据加密

import CryptoKit

class EncryptionManager {
    // 生成密钥
    static func generateKey() -> SymmetricKey {
        return SymmetricKey(size: .bits256)
    }

    // 加密数据
    static func encrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try AES.GCM.seal(data, using: key)
        return sealedBox.combined!
    }

    // 解密数据
    static func decrypt(data: Data, key: SymmetricKey) throws -> Data {
        let sealedBox = try AES.GCM.SealedBox(combined: data)
        return try AES.GCM.open(sealedBox, using: key)
    }
}

2. 生物识别认证

import LocalAuthentication

class BiometricAuthManager {
    func authenticate() async throws -> Bool {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            throw BiometricError.notAvailable
        }

        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "验证身份以访问健康数据"
        )
    }
}

enum BiometricError: Error {
    case notAvailable
    case authenticationFailed
}

3. 证书固定

class CertificatePinningDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        guard let serverTrust = challenge.protectionSpace.serverTrust,
              let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        let serverCertificateData = SecCertificateCopyData(certificate) as Data

        // 比较证书
        if let localCertPath = Bundle.main.path(forResource: "certificate", ofType: "cer"),
           let localCertData = try? Data(contentsOf: URL(fileURLWithPath: localCertPath)),
           serverCertificateData == localCertData {
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

测试

单元测试

import XCTest
@testable import HealthApp

class HealthKitManagerTests: XCTestCase {
    var sut: HealthKitManager!

    override func setUp() {
        super.setUp()
        sut = HealthKitManager()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testHealthKitAvailability() {
        // Given & When
        let isAvailable = sut.isHealthKitAvailable()

        // Then
        XCTAssertTrue(isAvailable)
    }

    func testSaveWeight() async throws {
        // Given
        let weight = 70.0

        // When
        try await sut.saveWeight(weight)

        // Then
        // 验证数据已保存
    }
}

UI测试

import XCTest

class HealthAppUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testHealthDashboardDisplay() {
        // Given
        let heartRateLabel = app.staticTexts["心率"]

        // Then
        XCTAssertTrue(heartRateLabel.exists)
    }

    func testMedicationReminderFlow() {
        // Given
        app.buttons["添加提醒"].tap()

        // When
        let medicationField = app.textFields["药物名称"]
        medicationField.tap()
        medicationField.typeText("阿司匹林")

        app.buttons["保存"].tap()

        // Then
        XCTAssertTrue(app.staticTexts["阿司匹林"].exists)
    }
}

App Store提交

1. 准备工作

  • 完整的应用功能
  • 所有测试通过
  • 隐私政策和使用条款
  • 应用图标和截图
  • 应用描述和关键词

2. 医疗应用特殊要求

<!-- Info.plist 必需配置 -->
<key>NSHealthShareUsageDescription</key>
<string>详细说明为什么需要读取健康数据</string>

<key>NSHealthUpdateUsageDescription</key>
<string>详细说明为什么需要写入健康数据</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>如果使用位置服务,说明原因</string>

3. App Store审核指南

  • 医疗应用必须提供隐私政策
  • 如果是医疗器械,需要提供监管批准文件
  • 不得包含误导性的健康声明
  • 必须明确说明数据使用方式
  • 需要提供测试账号(如适用)

性能优化

1. 启动时间优化

// 延迟非关键初始化
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // 关键初始化
    setupWindow()

    // 延迟初始化
    DispatchQueue.main.async {
        self.setupAnalytics()
        self.setupCrashReporting()
    }

    return true
}

2. 内存管理

class ImageCache {
    private var cache = NSCache<NSString, UIImage>()

    init() {
        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024 // 50MB
    }

    func setImage(_ image: UIImage, forKey key: String) {
        cache.setObject(image, forKey: key as NSString)
    }

    func image(forKey key: String) -> UIImage? {
        return cache.object(forKey: key as NSString)
    }
}

3. 后台任务

class BackgroundTaskManager {
    func scheduleHealthDataSync() {
        let request = BGAppRefreshTaskRequest(identifier: "com.app.healthsync")
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15分钟后

        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            print("后台任务调度失败: \(error)")
        }
    }
}

常见问题

Q: HealthKit数据在模拟器上不可用?

A: HealthKit仅在真实设备上可用,需要使用真机进行测试。

Q: 如何处理用户拒绝健康数据授权?

A: 提供清晰的说明,引导用户到设置中手动授权,并提供降级功能。

Q: 应用需要医疗器械认证吗?

A: 取决于应用功能。如果用于诊断、治疗或预防疾病,可能需要FDA或其他监管机构批准。

相关资源

下一步


最后更新: 2024年


💬 讨论区

欢迎在这里分享您的想法、提出问题或参与讨论。需要 GitHub 账号登录。