Post

Swift iOS电话辨识实作|Call Directory Extension离线号码标记完整攻略

针对iOS开发者,解决无法即时辨识来电的痛点,透过Swift与Call Directory Extension离线资料库技术,打造本地电话号码标记功能,提升用户接听体验并避免漏接重要来电。

Swift iOS电话辨识实作|Call Directory Extension离线号码标记完整攻略

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


自己的电话自己辨识(Swift)

iOS自干 Whoscall 来电辨识、电话号码标记 功能

起源

一直以来都是Whoscall的忠实用户,从原本用Android手机时就有使用,能够非常即时的显示陌生来电资讯,当下就能直接决定接通与否;后来转跳苹果阵营,第一只苹果手机是iPhone 6 (iOS 9),那时在使用Whoscall上非常别扭,无法即时辨识电话,要复制电话号码去APP查询,后期Whoscall提供将陌生电话资料库安装在本地手机的服务,虽然能解决即时辨识的问题,但很容易就弄乱你的手机通讯录!

直到 iOS 10+ 之后苹果开放电话辨识功能(Call Directory Extension)权限给开发者,才使whoscall目前至少就体验来说已和Android版无太大缺别,甚至超越Android版(Android版广告超多,但以开发者的立场是可以理解的)

用途?

Call Directory Extension 能做到什么呢?

  1. 电话 拨打 辨识标记

  2. 电话 来电 辨识标记

  3. 通话纪录 辨识标记

  4. 电话 拒接 黑名单设置

限制?

  1. 使用者需手动进入「设定」「电话」「通话封锁与识别」打开您的APP才能使用

  2. 仅能以离线资料库方式辨识电话(无法即时取得来电资讯然后Call API查询,仅能预先写入号码<->名称对应在手机资料库中) *也因此Whoscall会定期推播请使用者开APP更新来电辨识资料库

  3. 数量上限?目前没查到资料,应该是依照使用者手机容量无特别上限;但是数量多得辨识清单、封锁清单要分批处理写入!

  4. 软体限制:iOS 版本需 ≥ 10

「设定」->「电话」->「通话封锁与识别」

「设定」->「电话」->「通话封锁与识别」

应用场景?

  1. 通讯软体、办公室通讯软体;在APP内你可能有对方的联络人,但实际并未将手机号码加入手机通讯录中,这个功能就能避免同事甚至老板来电时,被当陌生电话,结果漏接.

  2. 敝站( 结婚吧 )或敝私的( 591房屋交易 ),使用者与店家或房东联系时所拨打的电话都是我们的转接号码,经由转接中心在转拨到目标电话,大致流程如下:

使用者所拨打的电话都是转接中心代表号( #分机),不会知道真实的电话号码;一方面是保护个资隐私、另一方面也能知道有多少人联络商家(评估成效)甚至能知道是在哪看到然后拨打的(EX:网页显示#1234,APP显示#5678)、还有也能推免费服务,由我方吸收电话通信费用.

但此做法会带来ㄧ项不可避免的问题,就是电话号码凌乱;无法辨识出是打给谁或是店家回拨时,使用者不知道来电者是谁,透过使用电话辨识功能就能大大解决这个问题,提升使用者体验!

直接上一张成品图:

[结婚吧 APP](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E6%9C%80%E5%A4%A7%E5%A9%9A%E7%A6%AE%E7%B1%8C%E5%82%99app/id1356057329?mt=8){:target="_blank"}

结婚吧 APP

可以看到在输入电话、电话来电时能直接显示辨识结果、通话记录列表也不在乱糟糟ㄧ样能在下方显示辨识结果.

Call Directory Extension 电话辨识功能运作流程:

开工:

让我们开始动手做吧!

1.为 iOS 专案加入 Call Directory Extension

Xcode -> File -> New -> Target

Xcode -> File -> New -> Target

选择 Call Directory Extension

选择 Call Directory Extension

输入Extension名称

输入Extension名称

可顺带加入 Scheme 方便 Debug

可顺带加入 Scheme 方便 Debug

目录底下就会出现Call Directory Extension的资料夹及程式

目录底下就会出现Call Directory Extension的资料夹及程式

2.开始编写 Call Directory Extension 相关程式

首先回到主 iOS 专案上

第一个问题是我们该如何判断使用者的装置支不支援Call Directory Extension或是设定中的「通话封锁与识别」是否已经打开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import CallKit
//
//......
//
if #available(iOS 10.0, *) {
    CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(withIdentifier: "这里输入call directory extension的bundle identifier", completionHandler: { (status, error) in
        if status == .enabled {
          //启用中
        } else if status == .disabled {
          //未启用
        } else {
          //未知,不支援
        }
    })
}

前面有提到,来电辨识的运作方式是要在本地维护一个辨识资料库;再来就是重头戏该如何达成这个功能?

很遗憾,您无法直接对Call Directory Extension进行呼叫写入资料,所以你需要多维护一层对应结构,然后Call Directory Extension再去读取你的结构再写入辨识资料库中,流程如下:

意旨我们需要多维护一个自己的资料库文件,再让Extenstion去读取写入到手机中

意旨我们需要多维护一个自己的资料库文件,再让Extenstion去读取写入到手机中

那所谓的辨识资料、档案该长怎样?

其实就是个Dictionary结构,如:[“电话”:”王大明”]

存在本地的档案可用一些Local DB(但Extension那边也要能装能用),这边是直接存一个.json档在手机里; 不建议直接存在UserDefaults,如果是测试或资料很少可以,实际应用强烈不建议!

好的,开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if #available(iOS 10.0, *) {
    if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "你的跨Extesion,Group Identifier名称") {
        let fileURL = dir.appendingPathComponent("phoneIdentity.json")
        var datas:[String:String] = ["8869190001234":"李先生","886912002456":"大帅"]
        if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let json2 = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String,String>,let json = json2 {
            datas = json
        }
        if let data = jsonToData(jsonDic: datas) {
            DispatchQueue(label: "phoneIdentity").async {
                if let _ = try? data.write(to: fileURL) {
                    //写入json档完成
                }
            }
        }
    }
}

就只是一般的本地档案维护,要注意的就是目录需要在Extesion也能读取的地方。

补充 — 电话号码格式:

  1. 台湾地区市话、手机都需去掉0以886代替:如 0255667788 -> 886255667788

  2. 电话格式是纯数字组合的字串,勿夹杂「-」、「,」、「#」…等符号

  3. 市话电话如有包含要辨识到 分机 ,直接接在后面即可不需带任何符号:如 0255667788,0718 -> 8862556677880718

  4. 将一般iOS电话格式转换成辨识资料库可接受格式可参考以下两个取代方法:

1
2
3
4
5
6
7
var newNumber = "0255667788,0718"
if let regex = try? NSRegularExpression(pattern: "^0{1}") {
    newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "886")
}
if let regex = try? NSRegularExpression(pattern: ",") {
    newNumber = regex.stringByReplacingMatches(in: newNumber, options: [], range: NSRange(location: 0, length: newNumber.count), withTemplate: "")
}

再来就是如流程,辨识资料已维护好;需要通知Call Directory Extension去刷新手机那边的资料:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
if #available(iOS 10.0, *) {
    CXCallDirectoryManager.sharedInstance.reloadExtension(withIdentifier: "tw.com.marry.MarryiOS.CallDirectory") { errorOrNil in
        if let error = errorOrNil as? CXErrorCodeCallDirectoryManagerError {
            print("reload failed")
            
            switch error.code {
            case .unknown:
                print("error is unknown")
            case .noExtensionFound:
                print("error is noExtensionFound")
            case .loadingInterrupted:
                print("error is loadingInterrupted")
            case .entriesOutOfOrder:
                print("error is entriesOutOfOrder")
            case .duplicateEntries:
                print("error is duplicateEntries")
            case .maximumEntriesExceeded:
                print("maximumEntriesExceeded")
            case .extensionDisabled:
                print("extensionDisabled")
            case .currentlyLoading:
                print("currentlyLoading")
            case .unexpectedIncrementalRemoval:
                print("unexpectedIncrementalRemoval")
            }
        } else if let error = errorOrNil {
            print("reload error: \(error)")
        } else {
            print("reload succeeded")
        }
    }
}

使用以上方法通知Extension刷新,并取得执行结果。(这时候会呼叫执行Call Directory Extension里的beginRequest,请继续往下看)

主 iOS 专案的程式就到这了!

3.开始修改 Call Directory Extension 的程式

打开Call Directory Extension 目录,找到底下已经帮你建立好的档案 CallDirectoryHandler.swift

能实作的方法只有 beginRequest 当要处理手机电话资料时的动作,预设范例都把我们建好了,不太需要去动:

  1. addAllBlockingPhoneNumbers :处理加入黑名单号码(全新增)

  2. addOrRemoveIncrementalBlockingPhoneNumbers :处理加入黑名单号码(递增方式)

  3. addAllIdentificationPhoneNumbers :处理加入来电辨识号码(全新增)

  4. addOrRemoveIncrementalIdentificationPhoneNumbers :处理加入来电辨识号码(递增方式)

我们只要完成以上的Function实作即可,黑名单功能跟来电辨识方式原理都ㄧ样这边就不多作介绍.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
private func fetchAll(context: CXCallDirectoryExtensionContext) {
    if let dir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "你的跨Extesion,Group Identifier名称") {
        let fileURL = dir.appendingPathComponent("phoneIdentity.json")
        if let content = try? String(contentsOf: fileURL, encoding: .utf8),let text = content.data(using: .utf8),let numbers = try? JSONSerialization.jsonObject(with: text, options: .mutableContainers) as? Dictionary<String,String> {
            numbers?.sorted(by: { (Int($0.key) ?? 0) < Int($1.key) ?? 0 }).forEach({ (obj) in
                if let number = CXCallDirectoryPhoneNumber(obj.key) {
                    autoreleasepool{
                        if context.isIncremental {
                            context.removeIdentificationEntry(withPhoneNumber: number)
                        }
                        context.addIdentificationEntry(withNextSequentialPhoneNumber: number, label: obj.value)
                    }
                }
            })
        }
    }
}

private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
    // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers,
    // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
    //
    // Numbers must be provided in numerically ascending order.
    //        let allPhoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1_877_555_5555, 1_888_555_5555 ]
    //        let labels = [ "Telemarketer", "Local business" ]
    //
    //        for (phoneNumber, label) in zip(allPhoneNumbers, labels) {
    //            context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label)
    //        }
    fetchAll(context: context)
}

private func addOrRemoveIncrementalIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
    // Retrieve any changes to the set of phone numbers to identify (and their identification labels) from data store. For optimal performance and memory usage when there are many phone numbers,
    // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
    //        let phoneNumbersToAdd: [CXCallDirectoryPhoneNumber] = [ 1_408_555_5678 ]
    //        let labelsToAdd = [ "New local business" ]
    //
    //        for (phoneNumber, label) in zip(phoneNumbersToAdd, labelsToAdd) {
    //            context.addIdentificationEntry(withNextSequentialPhoneNumber: phoneNumber, label: label)
    //        }
    //
    //        let phoneNumbersToRemove: [CXCallDirectoryPhoneNumber] = [ 1_888_555_5555 ]
    //
    //        for phoneNumber in phoneNumbersToRemove {
    //            context.removeIdentificationEntry(withPhoneNumber: phoneNumber)
    //        }
    
    //context.removeIdentificationEntry(withPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!)
    //context.addIdentificationEntry(withNextSequentialPhoneNumber: CXCallDirectoryPhoneNumber("886277283610")!, label: "TEST")
    
    fetchAll(context: context)
    // Record the most-recently loaded set of identification entries in data store for the next incremental load...
}

因为敝站的资料不会到太多而且我的本地资料结构相当简易,无法做到递增;所以这边 统一都用全新增的方式,如是递增方式则要先删除旧的(这步很重要不然会reload extensiton失败!)

完工!

到此为止就完成啰!实作方面非常简单!

Tips:

  1. 如果在「设定」「电话」「通话封锁与识别」打开APP时一直转或是打开后无法辨识号码,可先确认号码是否正确、本地维护的.json资料是否正确、reload extensiton是否成功;或重开机试试,都找不出来可以选call directory extension的Scheme Build 看看错误讯息.

  2. 这个功能 最困难的点不是程式方面而是要引导使用者手动去设定打开 ,具体方式及引导可参考whoscall:

[Whoscall](https://whoscall.com/zh-TW/){:target="_blank"}

Whoscall

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.