Meet FinanceKit
System Frameworks 进阶 20m

认识 FinanceKit

Meet FinanceKit

2024年6月10日

在 Apple 官方观看视频

一句话判断

FinanceKit 是 Apple 在 iOS 18 中放出的金融数据访问框架——如果你在做记账、预算或理财类 App,这是今年最值得研究的新框架,没有之一。

这场 Session 讲了什么

FinanceKit 是 iOS 18 引入的新框架,提供了访问 Apple Wallet 中金融数据的标准化 API。它能聚合多个数据源(Apple Card、Apple Savings、Apple Cash),所有数据都在设备本地处理,不需要网络连接。

框架暴露了三个核心数据模型:Account(账户)、Balance(余额)和 Transaction(交易)。Account 可以是支票账户、信用卡或 Apple Savings。Transaction 描述了资金的流入流出,包含交易日期、金额、商户名称、商户类别码(ISO 18245)等字段。金额以正数十进制存储,方向通过 credit/debit indicator 区分。

数据访问有三种方式。最简单的是 Transaction Picker——一个系统级 UI 组件,用户手动选择要分享的交易记录。然后是一次性查询 API,获取特定条件的金融数据。最后是长连接查询 API,持续监听金融数据变化。每种方式的权限模型不同,Picker 是临时授权,查询 API 需要用户显式授权。

值得深挖的点

credit/debit indicator 与账户类型的矩阵关系

FinanceKit 中金额的语义解读不像表面看起来那么直觉。金额始终存储为正数,方向通过 creditDebitIndicator 判断。但 debit 和 credit 的含义取决于账户类型。对于资产类账户(asset account,比如 Apple Cash),debit 表示余额减少(花了钱),credit 表示余额增加(收到钱)。对于负债类账户(liability account,比如信用卡),debit 表示可用额度减少(刷了卡),credit 表示可用额度增加(还了款)。

这个设计符合会计准则,但对开发者来说需要格外注意。如果你在 UI 上展示”这笔交易花了多少钱”或”这笔交易赚了多少钱”,不能只看 indicator,还要结合账户类型。一个信用卡的 debit 意味着”你花了钱”,但一个储蓄账户的 debit 也意味着”你花了钱”——虽然 indicator 值相同,但在不同账户类型下的业务解读是一样的。

Transaction Picker 的临时授权模型

Transaction Picker 是一个借鉴了 PHPickerViewController 设计思路的系统组件。用户在 Picker 中选择交易记录后,数据直接传递给你的 App。但关键限制是:这个授权是临时的(ephemeral)。Picker 不会记住用户选择过什么,App 获得的数据只存在于当前会话。

这意味着 Picker 适合”用户主动分享特定交易”的场景(比如把一笔消费导入记账 App),但不适合”持续同步所有交易”的场景。后者需要使用查询 API 并获取用户的持久授权。Session 建议根据功能需求选择合适的数据访问方式——不要为了省事用 Picker 做本该用查询 API 做的事。

代码片段

场景一:检查数据可用性

import FinanceKit

// 第一步:检查设备是否支持 FinanceKit
let isAvailable = FinanceStore.isDataAvailable(.financialData)

if !isAvailable {
    // 数据不可用,不应调用任何其他 FinanceKit API
    // 框架会直接终止 App,这是有意为之的高强度信号
    return
}

// 检查数据是否被限制
// 注意:这个值是动态的,可能在 App 运行期间变化
// 比如设备管理策略变更会导致数据突然受限
do {
    let store = FinanceStore()
    try await store.requestAuthorization(for: [.transactions])
} catch {
    // 处理数据受限的情况
    // 坑点:数据可用性(isDataAvailable)在 App 生命周期内不会变
    // 但数据限制状态(restriction)可能随时变化
    // 需要在每次访问数据前都做好准备
}

场景二:使用 Transaction Picker

import FinanceKitUI
import SwiftUI

struct TransactionPickerView: UIViewControllerRepresentable {
    @Binding var selectedTransactions: [FinanceKit.Transaction]

    func makeUIViewController(context: Context) -> TransactionPickerViewController {
        let picker = TransactionPickerViewController()

        // 配置 picker
        picker.delegate = context.coordinator

        // 用户可以在 picker 中搜索和筛选交易
        // 支持文本搜索和 token 筛选
        return picker
    }

    func updateUIViewController(
        _ uiViewController: TransactionPickerViewController,
        context: Context
    ) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, TransactionPickerDelegate {
        let parent: TransactionPickerView

        func transactionPicker(
            _ picker: TransactionPickerViewController,
            didPick transactions: [FinanceKit.Transaction]
        ) {
            // 获得用户选中的交易
            parent.selectedTransactions = transactions

            // 坑点:授权是临时的!
            // 这些交易数据只在当前回调中可用
            // 不会持久化,下次打开 App 需要重新选择
            // 如果需要持久访问,应该使用查询 API
        }

        func transactionPickerDidCancel(
            _ picker: TransactionPickerViewController
        ) {
            // 用户取消了选择
        }
    }
}

场景三:查询账户和交易数据

import FinanceKit

func fetchAccountTransactions() async throws {
    let store = FinanceStore()

    // 查询所有账户
    let accounts = try await store.accounts()

    for account in accounts {
        // 账户余额信息
        if let balance = account.currentBalance {
            print("余额: \(balance.amount) \(balance.currencyCode)")
            print("最后更新: \(balance.asOfDate)")
        }

        // 查询该账户的交易记录
        let startDate = Calendar.current.date(
            byAdding: .month, value: -1, to: Date()
        )!

        var descriptor = TransactionQueryDescriptor()
        descriptor.startDate = startDate
        descriptor.endDate = Date()

        let transactions = try await store.transactions(matching: descriptor)

        for transaction in transactions {
            let amount = transaction.amount
            let isDebit = transaction.creditDebitIndicator == .debit

            // 解读金额时必须结合账户类型
            let isExpense: Bool
            switch account.type {
            case .asset:
                // 资产账户:debit = 支出
                isExpense = isDebit
            case .liability:
                // 负债账户(信用卡):debit = 消费
                isExpense = isDebit
            default:
                isExpense = isDebit
            }

            print("""
            商户: \(transaction.merchantName ?? "未知")
            金额: \(amount.value) \(amount.currencyCode)
            类型: \(isExpense ? "支出" : "收入")
            日期: \(transaction.transactionDate)
            """)
        }
    }
    // 坑点:transaction 的 identifier 在每台设备上都是唯一的
    // 不能用 identifier 做跨设备的唯一标识
    // 跨设备同步需要用其他字段组合作为幂等键
}

最佳实践

  • 迁移建议:如果你的 App 是记账或理财类应用,FinanceKit 提供了一个标准化的数据获取渠道,省去了和各家银行 API 对接的麻烦。建议先用 Transaction Picker 实现”手动导入交易”功能,验证数据质量和用户接受度,再考虑接入查询 API 实现自动同步。
  • 数据可用性检查isDataAvailable 应该作为使用 FinanceKit 的前置检查。返回 false 时调用任何 FinanceKit API 都会导致 App 被终止——这不是建议,是框架的强制行为。
  • 金额解读:始终结合账户类型和 credit/debit indicator 来判断交易的实际含义。不要假设 debit 就是”支出”——在信用卡场景下这个理解碰巧是对的,但在储蓄账户还款场景下可能需要反过来解读。

还有什么值得关注

  • FinanceKit 的数据完全在设备本地,不需要网络,这为离线场景下的金融数据分析提供了可能
  • 交易数据包含 ISO 18245 商户类别码,可以用来做自动化的消费分类
  • 外币交易会同时提供本币和外币金额,但 FinanceKit 本身不做汇率转换——数据格式就是金融机构提供的原始格式
WWDC 2024