mcpField
C
contacts-framework

contacts-framework

github.com/dpearson2699开发1.2k3.7k 次安装13 周前更新
代码仓库

关于 contacts-framework

面向 Swift 6.3 / iOS 26+ 开发者的 Contacts 框架技能,涵盖联系人读取、保存与选择。重点说明授权状态、按需获取属性及保存错误处理。

主要功能

  1. 01读取、创建、更新和删除联系人
  2. 02按条件或标识符查询
  3. 03通过系统选择器选择联系人
  4. 04监听联系人数据库变化
  5. 05处理授权与有限访问

适用场景

  1. 01在应用中实现联系人列表或搜索
  2. 02添加或修改联系人资料
  3. 03让用户选择联系人而不申请完整访问权限

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill contacts-framework

安装后在 Claude Code 中输入/contacts-framework确认已加载。

附带文件evals/evals.jsonreferences/contacts-patterns.md

详细介绍

联系人框架

在 Swift 6.3 / iOS 26+ 应用中,使用 CNContactStore、CNSaveRequest 和 CNContactPickerViewController 获取、创建、更新或选择联系人。

目录

设置

项目配置
  1. 在 Info.plist 中添加 NSContactsUsageDescription,说明应用访问联系人的原因。如果应用使用联系人数据 API 却未包含此键,会导致崩溃。
  2. 普通的联系人访问不需要额外的功能或授权。
  3. 仅在读取或写入 CNContactNoteKey / CNContact.note 时添加 com.apple.developer.contacts.notes;此授权需要获得 Apple 批准后才能公开发布。
导入
@preconcurrency import Contacts  // CNContactStore, CNSaveRequest, CNContact
import ContactsUI                // CNContactPickerViewController

授权

获取或保存联系人前,请先请求访问权限。选择器(CNContactPickerViewController)不需要授权——系统仅会授予访问用户所选联系人的权限。

let store = CNContactStore()

func requestAccess() async throws -> Bool {
    return try await store.requestAccess(for: .contacts)
}

// Check current status without prompting
func checkStatus() -> CNAuthorizationStatus {
    CNContactStore.authorizationStatus(for: .contacts)
}
授权状态
状态含义
.notDetermined尚未向用户请求授权
.authorized已授予完整的读写权限
.denied用户拒绝了访问;请引导用户前往“设置”
.restricted家长控制或 MDM 限制了访问
.limitediOS 18+:用户仅授予访问所选联系人的权限

.authorized 和 .limited 都应视为可用的 Contacts API 状态。在 .limited 状态下,获取、编辑和删除操作仅适用于用户授权的联系人或应用创建的联系人。使用 ContactAccessButton 或 contactAccessPicker(isPresented:completionHandler:),让用户将联系人添加到应用的有限访问范围。

获取联系人

使用 unifiedContacts(matching:keysToFetch:) 执行基于谓词的查询。 使用 enumerateContacts(with:usingBlock:) 批量枚举所有联系人。 对于较大的缓存通讯录,请先获取标识符,再按标识符分批获取详细联系人信息。

按姓名获取
func fetchContacts(named name: String) throws -> [CNContact] {
    let predicate = CNContact.predicateForContacts(matchingName: name)
    let keys: [CNKeyDescriptor] = [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor,
        CNContactPhoneNumbersKey as CNKeyDescriptor
    ]
    return try store.unifiedContacts(matching: predicate, keysToFetch: keys)
}
按标识符获取
func fetchContact(identifier: String) throws -> CNContact {
    let keys: [CNKeyDescriptor] = [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor,
        CNContactEmailAddressesKey as CNKeyDescriptor
    ]
    return try store.unifiedContact(withIdentifier: identifier, keysToFetch: keys)
}
枚举所有联系人

在主线程之外执行 I/O 密集型枚举操作。

func fetchAllContacts() throws -> [CNContact] {
    let keys: [CNKeyDescriptor] = [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor
    ]
    let request = CNContactFetchRequest(keysToFetch: keys)
    request.sortOrder = .givenName

    var contacts: [CNContact] = []
    try store.enumerateContacts(with: request) { contact, _ in
        contacts.append(contact)
    }
    return contacts
}

键描述符

仅获取所需的属性。访问尚未获取的属性会抛出 CNContactPropertyNotFetchedException。

常用键
键属性
CNContactGivenNameKey名字
CNContactFamilyNameKey姓氏
CNContactPhoneNumbersKey电话号码数组
CNContactEmailAddressesKey电子邮件地址数组
CNContactPostalAddressesKey邮寄地址数组
CNContactImageDataKey全分辨率联系人照片
CNContactThumbnailImageDataKey联系人缩略照片
CNContactBirthdayKey生日日期组件
CNContactOrganizationNameKey公司名称
复合键描述符

使用 CNContactFormatter.descriptorForRequiredKeys(for:) 获取格式化联系人姓名所需的所有键。

let nameKeys = CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
let keys: [CNKeyDescriptor] = [nameKeys, CNContactPhoneNumbersKey as CNKeyDescriptor]

创建和更新联系人

使用 CNMutableContact 构建新联系人,并使用 CNSaveRequest 保存更改。

创建新联系人
func createContact(givenName: String, familyName: String, phone: String) throws {
    let contact = CNMutableContact()
    contact.givenName = givenName
    contact.familyName = familyName
    contact.phoneNumbers = [
        CNLabeledValue(
            label: CNLabelPhoneNumberMobile,
            value: CNPhoneNumber(stringValue: phone)
        )
    ]

    let saveRequest = CNSaveRequest()
    saveRequest.add(contact, toContainerWithIdentifier: nil) // nil = default container
    try store.execute(saveRequest)
}
更新现有联系人

必须获取包含要修改属性的联系人,创建一个可变副本,更改属性,然后保存。

func updateContactEmail(identifier: String, email: String) throws {
    let keys: [CNKeyDescriptor] = [
        CNContactEmailAddressesKey as CNKeyDescriptor
    ]
    let contact = try store.unifiedContact(withIdentifier: identifier, keysToFetch: keys)
    guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }

    mutable.emailAddresses.append(
        CNLabeledValue(label: CNLabelWork, value: email as NSString)
    )

    let saveRequest = CNSaveRequest()
    saveRequest.update(mutable)
    try store.execute(saveRequest)
}
删除联系人
func deleteContact(identifier: String) throws {
    let keys: [CNKeyDescriptor] = [CNContactIdentifierKey as CNKeyDescriptor]
    let contact = try store.unifiedContact(withIdentifier: identifier, keysToFetch: keys)
    guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }

    let saveRequest = CNSaveRequest()
    saveRequest.delete(mutable)
    try store.execute(saveRequest)
}
保存结果与恢复

try store.execute(saveRequest) 返回且未抛出错误,即表示保存成功。只有在该调用返回后,才更新应用端缓存或成功状态界面。如果调用抛出错误,请显示或传递该错误,保留用户尚未保存的操作意图,并先修正已知原因(例如授权问题、容器为只读或输入无效),再构建新的请求。串行处理重叠的保存操作;execute(_:) 正在使用请求时,不要访问该请求;如果权限允许,在修正后重试前重新获取可能已过期的联系人。不要盲目重复相同的破坏性请求,也不要强制要求进行当前访问级别可能不允许的通用回读。对于多选、vCard 和优化搜索工作流,请阅读 Extended Contacts Patterns。

联系人选择器

CNContactPickerViewController 允许用户选择联系人,而无需授予完整的“通讯录”访问权限。应用只会收到用户选中的联系人数据。

SwiftUI 封装
import SwiftUI
import ContactsUI

struct ContactPicker: UIViewControllerRepresentable {
    @Binding var selectedContact: CNContact?

    func makeUIViewController(context: Context) -> CNContactPickerViewController {
        let picker = CNContactPickerViewController()
        picker.delegate = context.coordinator
        return picker
    }

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

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

    final class Coordinator: NSObject, CNContactPickerDelegate {
        let parent: ContactPicker

        init(_ parent: ContactPicker) {
            self.parent = parent
        }

        func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
            parent.selectedContact = contact
        }

        func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
            parent.selectedContact = nil
        }
    }
}
使用选择器
struct ContactSelectionView: View {
    @State private var selectedContact: CNContact?
    @State private var showPicker = false

    var body: some View {
        VStack {
            if let contact = selectedContact {
                Text("\(contact.givenName) \(contact.familyName)")
            }
            Button("Select Contact") {
                showPicker = true
            }
        }
        .sheet(isPresented: $showPicker) {
            ContactPicker(selectedContact: $selectedContact)
        }
    }
}
筛选选择器内容

使用谓词控制显示哪些联系人,以及用户可以选择哪些联系人。

let picker = CNContactPickerViewController()
// Only show contacts that have an email address
picker.predicateForEnablingContact = NSPredicate(format: "emailAddresses.@count > 0")
// Selecting a contact returns it directly (no detail card)
picker.predicateForSelectionOfContact = NSPredicate(value: true)

监听更改

监听外部通讯录数据库的更改,以刷新缓存数据。

func observeContactChanges() {
    NotificationCenter.default.addObserver(
        forName: .CNContactStoreDidChange,
        object: nil,
        queue: .main
    ) { _ in
        // Refetch contacts -- cached CNContact objects are stale
        refreshContacts()
    }
}

常见错误

不要:只需姓名时却获取所有键

过度获取会浪费内存并降低查询速度,尤其是对于包含大幅照片的联系人。

// WRONG: Fetches far more than the UI displays, including full-resolution photos
let keys: [CNKeyDescriptor] = [
    CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
    CNContactImageDataKey as CNKeyDescriptor,
    CNContactPhoneNumbersKey as CNKeyDescriptor,
    CNContactEmailAddressesKey as CNKeyDescriptor,
    CNContactPostalAddressesKey as CNKeyDescriptor,
    CNContactBirthdayKey as CNKeyDescriptor
]

// CORRECT: Fetch only what you display
let keys: [CNKeyDescriptor] = [
    CNContactGivenNameKey as CNKeyDescriptor,
    CNContactFamilyNameKey as CNKeyDescriptor
]
不要:访问未获取的属性

访问未包含在 keysToFetch 中的属性会在运行时抛出 CNContactPropertyNotFetchedException。

// WRONG: Only fetched name keys, now accessing phone
let keys: [CNKeyDescriptor] = [CNContactGivenNameKey as CNKeyDescriptor]
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
let phone = contact.phoneNumbers.first // CRASH

// CORRECT: Include the key you need
let keys: [CNKeyDescriptor] = [
    CNContactGivenNameKey as CNKeyDescriptor,
    CNContactPhoneNumbersKey as CNKeyDescriptor
]
不要:直接修改 CNContact

CNContact 是不可变的。必须调用 mutableCopy() 来获取 CNMutableContact。

// WRONG: CNContact has no setter
let contact = try store.unifiedContact(withIdentifier: id, keysToFetch: keys)
contact.givenName = "New Name" // Compile error

// CORRECT: Create mutable copy
guard let mutable = contact.mutableCopy() as? CNMutableContact else { return }
mutable.givenName = "New Name"
不要:跳过授权并假定已有访问权限

不要让获取或保存调用成为用户首次看到授权请求的时机。如果状态为 .notDetermined,请请求访问权限;如果访问被拒绝,联系人操作会因授权错误而失败。

// WRONG: Jump straight to fetch
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)

// CORRECT: Check or request access first
let granted = try await store.requestAccess(for: .contacts)
guard granted else { return }
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
不要:在主线程上执行耗时的获取操作

enumerateContacts 会执行 I/O。在主线程上运行会阻塞 UI。 如果严格并发检查提示 CNContact 跨越任务或 Actor 边界,可在该文件中使用 @preconcurrency import Contacts,或在返回前将联系人映射为 Sendable 视图模型。

// WRONG: Main thread enumeration
func loadContacts() {
    try store.enumerateContacts(with: request) { contact, _ in ... }
}

// CORRECT: Run on a background thread
func loadContacts() async throws -> [CNContact] {
    try await Task.detached {
        var results: [CNContact] = []
        try store.enumerateContacts(with: request) { contact, _ in
            results.append(contact)
        }
        return results
    }.value
}

检查清单

  • 已将 NSContactsUsageDescription 添加到 Info.plist
  • 在获取或保存操作前调用了 requestAccess(for: .contacts)
  • 将 .limited 视为可用访问权限,同时注意所选联系人的相关限制
  • 用户需要扩大有限访问范围时,提供 ContactAccessButton 或 contactAccessPicker
  • 妥善处理授权被拒(引导用户前往“设置”)
  • 获取请求中只包含所需的 CNKeyDescriptor 键
  • 格式化姓名时使用了 CNContactFormatter.descriptorForRequiredKeys(for:)
  • 修改联系人前通过 mutableCopy() 创建了可变副本
  • 每项创建、更新和删除操作都使用 CNSaveRequest;只有在 execute(_:) 成功后才推进应用状态,并且在构建修正后的请求前显示失败信息
  • 在主线程之外运行耗时的获取操作(enumerateContacts)
  • 监听了 CNContactStoreDidChange,以刷新缓存的联系人
  • 不需要完整“通讯录”访问权限时,使用 CNContactPickerViewController
  • 在展示选择器视图控制器前设置了选择器谓词
  • 整个应用复用同一个 CNContactStore 实例

参考资料

评论

支持 Markdown