Build scalable enterprise app suites
System & Services 进阶 22m

构建可扩展的企业应用套件

Build scalable enterprise app suites

2020年6月24日

在 Apple 官方观看视频

一句话判断

如果你的企业有多个 App 需要共享数据和状态,iOS 14 的 App Group、Shared Keychain、和 UserDefaults Suite 让你的 App 套件像一个统一的平台一样运作。

这场 Session 讲了什么

大型企业通常有多款面向员工的 App——OA、审批、通讯录、报销等。这些 App 之间需要共享用户登录状态、基础配置和部分数据。Session 详细介绍了如何构建这样的”App 套件”。

核心内容包括:App Group 的配置和使用,允许多个 App 共享文件系统和 UserDefaults;Keychain Sharing,允许多个 App 共享安全凭据;URL Scheme 和 Universal Links 用于 App 之间的跳转和通信;MDM 的 Managed App Configuration 用于批量部署企业 App 的配置。Session 还讨论了如何通过 Shared Framework 来在多个 App 之间共享代码。

值得深挖的点

App Group 的正确使用

App Group 是 iOS 上多个 App 之间共享数据的主要机制。你在 Apple Developer Portal 中创建一个 App Group(如 group.com.enterprise.suite),然后在每个 App 的 entitlement 中声明这个 Group。共享的数据包括:UserDefaults(通过 UserDefaults(suiteName:))、文件系统(通过 FileManager.containerURL(forSecurityApplicationGroupIdentifier:))、和 CoreData 数据库。但要注意,App Group 中的数据没有额外的加密保护,不要存储敏感信息。

Shared Keychain 的安全共享

与 App Group 不同,Keychain Sharing 使用了 Secure Enclave 的硬件级保护。多个 App 可以通过 kSecAttrAccessGroup 共享 Keychain 条目。敏感数据(如认证令牌、加密密钥)应该存储在 Shared Keychain 中而非 App Group 中。

代码片段

配置 App Group 共享数据

场景:在多个企业 App 之间共享用户配置和登录状态。

import Foundation

class SharedConfiguration {
    // App Group 标识符(所有参与共享的 App 必须一致)
    private let appGroupIdentifier = "group.com.enterprise.suite"
    
    // 共享的 UserDefaults
    private var sharedDefaults: UserDefaults {
        return UserDefaults(suiteName: appGroupIdentifier)!
    }
    
    // 共享的文件目录
    private var sharedContainerURL: URL {
        return FileManager.default.containerURL(
            forSecurityApplicationGroupIdentifier: appGroupIdentifier
        )!
    }
    
    // MARK: - 共享用户配置
    
    var currentUserID: String? {
        get { sharedDefaults.string(forKey: "currentUserID") }
        set { sharedDefaults.set(newValue, forKey: "currentUserID") }
    }
    
    var serverEnvironment: String {
        get { sharedDefaults.string(forKey: "serverEnv") ?? "production" }
        set { 
            sharedDefaults.set(newValue, forKey: "serverEnv")
            // 通知其他 App 配置已更新
            sharedDefaults.set(Date(), forKey: "configUpdateTime")
        }
    }
    
    // MARK: - 共享文件存储
    
    func saveSharedData(_ data: Data, filename: String) throws {
        let fileURL = sharedContainerURL.appendingPathComponent(filename)
        try data.write(to: fileURL)
    }
    
    func loadSharedData(filename: String) throws -> Data {
        let fileURL = sharedContainerURL.appendingPathComponent(filename)
        return try Data(contentsOf: fileURL)
    }
}

使用 Keychain Sharing 共享认证令牌

场景:让用户登录一次后所有企业 App 自动获得认证。

import Security

class SharedKeychainManager {
    // Keychain Access Group(所有 App 必须在 entitlement 中声明)
    private let accessGroup = "ABCDE12345.com.enterprise.shared"
    
    /// 保存认证令牌到共享 Keychain
    func saveAuthToken(_ token: String, for user: String) throws {
        let tokenData = token.data(using: .utf8)!
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "auth_token_\(user)",
            kSecAttrAccessGroup as String: accessGroup,
            kSecValueData as String: tokenData,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        
        // 先删除旧的
        SecItemDelete(query as CFDictionary)
        
        // 添加新的
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status: status)
        }
    }
    
    /// 从共享 Keychain 读取认证令牌
    func loadAuthToken(for user: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "auth_token_\(user)",
            kSecAttrAccessGroup as String: accessGroup,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            return nil
        }
        
        return token
    }
    
    /// 删除认证令牌(用户注销时)
    func deleteAuthToken(for user: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: "auth_token_\(user)",
            kSecAttrAccessGroup as String: accessGroup
        ]
        
        SecItemDelete(query as CFDictionary)
    }
}

enum KeychainError: Error {
    case saveFailed(status: OSStatus)
}

App 之间的跳转和数据传递

场景:从 OA App 跳转到审批 App 并传递审批 ID。

class AppNavigationManager {
    
    /// 跳转到其他企业 App
    func openApprovalApp(approvalId: String) {
        // 方法一:使用 URL Scheme
        if let url = URL(string: "enterprise-approval://open?id=\(approvalId)") {
            if UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url)
            } else {
                // App 未安装,引导用户安装
                promptInstall(appName: "审批")
            }
        }
    }
    
    /// 使用 Universal Links 跳转(更安全)
    func openApprovalAppUniversal(approvalId: String) {
        if let url = URL(string: "https://app.enterprise.com/approval/\(approvalId)") {
            UIApplication.shared.open(url)
        }
    }
    
    /// 通过 Shared UserDefaults 传递复杂数据
    func passDataToOtherApp(data: [String: Any]) {
        let sharedConfig = SharedConfiguration()
        
        // 写入共享数据
        let jsonData = try? JSONSerialization.data(withJSONObject: data)
        try? sharedConfig.saveSharedData(jsonData!, filename: "pending_approval.json")
        
        // 设置标记
        sharedConfig.sharedDefaults.set(true, forKey: "has_pending_data")
        
        // 跳转到目标 App
        openApprovalApp(approvalId: data["id"] as? String ?? "")
    }
    
    private func promptInstall(appName: String) {
        // 提示用户安装企业 App
        if let url = URL(string: "https://apps.apple.com/app/idXXXXXXXXX") {
            UIApplication.shared.open(url)
        }
    }
}

读取 MDM 托管配置

场景:在 App 中读取 IT 管理员通过 MDM 下发的配置。

import Foundation

class ManagedConfigReader {
    
    /// 读取 MDM 下发的配置
    func readManagedConfiguration() -> [String: Any]? {
        // MDM 配置存储在 UserDefaults 的特定 key 中
        let defaults = UserDefaults.standard
        
        // 读取 managed configuration
        if let config = defaults.dictionary(forKey: "com.apple.configuration.managed") {
            return config
        }
        
        return nil
    }
    
    /// 获取企业服务器地址
    func getEnterpriseServerURL() -> String {
        if let config = readManagedConfiguration(),
           let serverURL = config["server_url"] as? String {
            return serverURL
        }
        
        // 没有托管配置时使用默认值
        return "https://api.enterprise.com"
    }
    
    /// 检查是否是托管设备
    func isManagedDevice() -> Bool {
        return UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") != nil
    }
}

最佳实践

已有项目:如果你有多个企业 App 但它们目前独立运行,规划一个统一的 App Group 和 Keychain Sharing 方案。先从共享登录状态开始——用户在一个 App 登录后,其他 App 自动获得认证。然后逐步共享配置、缓存和常用数据。

新项目:从架构设计阶段就考虑 App 套件的共享策略。创建一个 Shared Framework 包含公共的网络层、数据模型和工具类。所有企业 App 都依赖这个 Shared Framework,确保行为一致。

还有什么值得关注

  • App Group 的共享文件有容量限制(通常几十 MB),不要用它存储大文件。
  • Keychain Sharing 需要所有参与 App 使用相同的 Team ID。
  • MDM 的 Managed App Configuration 支持 InstallRemoveSettings 三种配置类型。
WWDC 2020