肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

循环结构中异步代码实现灵活退出(上)

Swift

在同步代码时,循环要提前退出十分简单。

for i in 1...10 {
    print(i)
    
    if i == 5 {
        break
    }
}

但是在异步代码中,要灵活退出就不那么容易了。比如,在调用RestAPI时,如果一切正常,就执行下一次循环,如果出错,则进行提示用户,进行重试或者退出。这个就属于异步操作。

import Cocoa

class ViewController: NSViewController {
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            if i == 5 {
                result = true
            }
        }
        
        return result
    }
}

由于采用了异步,shouldBreak(_ i:Int) -> Boolresult是先于DispatchQueue.main.async中的代码执行的。因此输出始终是1-10

为保证执行的顺序,需要使用信号量。

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
}

代码执行正确。下面我们加入改变UI的部分。添加一个NSTextView,让它显示每次的i

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        run()
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }
            
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
    
    @IBOutlet var textView: NSTextView!
}

代码执行后,我们会发现,textView中的i,不是一行一行显示的,而是一开始不显示,然后一下子都显示出来。这和我们期望的不符。

这是什么原因造成的呢?其实,这是因为视图控制器中的代码,默认运行在图形线程,因此semaphore.wait()其实每次都阻塞了图形线程。这导致textView一直没法刷新。直到循环跳出后,界面才成功刷新。

知道了原因,解决办法就有了。将代码从默认的图形线程中移除即可。最终代码:

import Cocoa

class ViewController: NSViewController {
    private let semaphore = DispatchSemaphore(value: 0)
    private let concurrentQueue = DispatchQueue.global()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        concurrentQueue.async {
            self.run()
        }
    }
    
    private func run() {
        for i in 1...10 {
            print(i)
            
            if shouldBreak(i) {
                break
            }
        }
    }
    
    private func shouldBreak(_ i:Int) -> Bool {
        var result = false
        let delayInSeconds = Int(arc4random() % 3)

        concurrentQueue.asyncAfter(wallDeadline: .now() + .seconds(delayInSeconds)) {
            DispatchQueue.main.async {
                self.textView.string += "\(i)\n"
            }
            
            if i == 5 {
                result = true
            }
            
            self.semaphore.signal()
        }
        
        semaphore.wait()
        
        return result
    }
    
    @IBOutlet var textView: NSTextView!
}

循环结构中异步代码实现灵活退出(下)

AVMovie始终显示视频时长为0问题的分析、解决

macOS

推特仅支持0.5秒到140秒之内长度的视频,因此上传前要先检验视频的长度。测试时,我发现,Finder分享的视频时长始终为0。

我查询了一下,最初看到的说法是,AVAssetAVMovie的父类)的加载是异步的,不能够立即获得时长,而需要用键值观察的方法,注册通知来观察duration这个属性何时变化。昨晚看到这里,我就睡了。

今天再看,发现有协议AVAsynchronousKeyValueLoading,可以用来异步处理键值。方法loadValuesAsynchronously(forKeys:completionHandler:)会在加载完成后执行completionHandler。这样就不用我们自己手动注册键值观察的通知了。

但是,我测试这个方法不好用。Finder传来的视频,无论是否使用这个异步方法,时长都始终为0。

我不用Finder,采用照片应用进行调试,发现无论是不是用这个异步方法,都能获得正确的时长。

结合上一篇Finder分享视频到共享扩展的一些限制的小结。我产生了新的想法,经过测试,我的猜想是正确的。

那就是,AVMovie在读取URL的时候,会正确获得时长,如果是Data的话,则不可以。想测试也很简单,把URL变成Data,再创建AVMovie;或者把Data写入到URL,再创建AVMovie。就可以证实了。

我不清楚这个是苹果API的错误,还是特性。但是这个至少是没有注明的。

Finder分享视频到共享扩展的一些限制的小结

macOS

通常,我们使用NSItemProviderloadItem(forTypeIdentifier:options:completionHandler:)方法来加载对应类型的对象。但是Finder提供的信息有问题。不能使用推荐的类型来获得想要的对象。

比如分享mp4文件时,Finder提供的NSItemProvider是这样的。

<NSItemProvider: 0x6000000bfa40> {types = (
    "public.file-url",
    "public.url",
    "public.mpeg-4"
)}

你用"public.mpeg-4"作为类型来获取数据,不能获得视频文件对应的URL。这是很罕见的。因为大部分情况下,比如通过照片应用来获得视频时,都可以通过这种方式获得URL。

Finder提供给"public.mpeg-4"实际上是一个Data。但是这个Data用在这里实际上是有问题的。苹果不应该这么做。为什么呢?因为相比于其它类型的文件,比如图片或者声音之类的,视频文件可以是很大的。如果你要使用这个视频文件,那么通过Data来加载,就会占据大量的内存。实际上,苹果自己就在加载视频的文档中写到,不要全部加载到内存。

apple data warning

因为存在这个限制,Finder实际上传来的Data可能是不完整的。比如我尝试分享一个4GB的视频时,实际获得的Data的大小是415MB,并不完整。

结论,既然Data既占内存,又可能不完整,为什么Finder还要提供呢?只能说,苹果做错了。

临时的处理办法是这样的,先用"public.mpeg-4"尝试获取URL,如果获取失败。那么就看NSItemProvider是否支持URL。如果支持就再用"public.url"尝试获取URL。如果还不行,那就认为是没法处理。为什么不反过来呢?因为大部分的情况,都是第一种方式就能直接处理。Finder这么做实际上是错误的。但是为了适应它,才需要调整我们的代码。

为什么说只有远程通知才是最吼的!(续)

watchOS

这篇文章会根据我的体验和理解进行增补和删减

如果是新增,那么内容会直接添加。
如果是修改或删减。我会将原有的段落标记加上删除线。然后再写上新段落。
如果出现多次修改和删减。我会添加版本的提示。如果没有提示,那么相邻的删除线的段落,就是同一个版本的。

简析远程推送服务的解决方案

苹果不承诺远程通知的可靠性。所以远程通知在实际使用中,经常有延迟,甚至丢失的情况。这时,你有两个选择。PushKit或调教苹果的apns服务器。

苹果不承诺远程通知的可靠性。并且由于我们在使用远程通知时,往往会使用第三方的推送服务。因此,一旦通知出现延迟甚至丢失,那么既有可能是苹果的问题,也可能是第三方服务的问题。就目前我自己的感受来说,苹果的服务要比第三方服务更可靠一些。

如果你的程序对于实时性要求较高。那么可以使用收费的第三方服务,这样可以保证你的推送的质量。或者使用自己的推送服务器。更进一步,你还可以使用PushKit

PushKit

PushKit是苹果伴随iOS 8推出的特殊通知,它针对的是一般远程通知的缺点。最初的设计目标是提供给VoIP的app以实时的通知。iOS 9的时候,苹果又增加了对于手表表盘上小部件的推送。

一般远程通知的缺陷

  1. 苹果不保证远程通知一定能到达。苹果会根据它的apns服务器的情况,动态来处理你的推送请求。也就是说,你的请求可能被执行,也可能会延迟,甚至被忽略(忘记)。
  2. 苹果的apns服务器最多只保留最后一条的推送请求。比如用户设备离线的情况下,你发出了多条通知,那么最终用户至多只能收到最后一条。
  3. 如果用户通过呼出任务管理,并且上滑移除app。那么该app的远程推送,将不能唤醒该app到后台,直到用户手动打开该app,或重启手机并解锁进入。
  4. 远程通知注册的token,隔一段时间会自动变化。这时如果还按照旧的申请,就会收不到消息。

苹果的处理方式

  1. 针对问题1。苹果保证PushKit通知会即时到达。看其他人写的评测,基本上延迟只有1秒。因为VoIP是类似打电话的功能,这个延迟高了,服务就没法用了。
  2. 用户设备可以在收到远程通知后,向自己的服务器,苹果称之为Provider,进行反馈。由于通知是即时的,服务器在“向要求苹果apns的服务器发出通知”的命令发出后,如果在特定时间没有收到用户设备的反馈,就可以认定用户的设备是离线的。这之后如果还需要给用户设备发通知,就可以先缓存起来,而不要发送到苹果的apns服务器。服务器可以在确认用户设备启动之后,再一次性将缓存的通知发出,这样,用户就可以得到在设备离线期间缓存的所有信息。
  3. PushKit的通知仍可以唤醒程序。
  4. PushKit的token是不变的。

PushKit的缺点

  • 实现比远程通知更为复杂。除了远程通知的要实现之外,还要向Provider做出反馈。
  • 国内没有现成的第三方服务,需要使用国外的第三方服务。或者自己编写Provider

调教苹果的apns服务器

如果你不想实现PushKit,那么你可以采用下面的比较脏的方式来调教苹果的apns服务器。

我的程序每小时通知一次设备。但是我发现,这个通知有时候会丢失,每天大概会丢个两回左右。

更进一步研究,我发现,如果通知没有及时到达,那么我可以在一段时间之后,补发一个通知。由于我没有实现PushKitProvider服务器不知道上一个通知到达了没有,因此我必须每小时发送两次通知。iOS端如果收到了第一个通知,那么可以做下标记,第二个通知到来时,就直接忽略,不要向Apple Watch传送就可以了。

这个调教的方式的原理是这样的。当apns服务器繁忙时,我猜测苹果采用了一个典型的美国式公平的策略。即大家都丢一些,而不是规定一个资源上限,占用资源多的丢,占用少的不丢。这导致一个现象,就是爱哭的孩子有糖吃。比如我的程序,原本每小时只需要一次推送。但是由于苹果的apns服务器会偶发的抛弃其中的1-2个通知。那么我为了保证通知的必须到达,就需要每小时发两次。实际上这导致苹果apns服务器的负载增加了一倍。但是我没有办法,因为只有这样,才能保证我的程序每小时至少刷新一次。

这个方法相对于PushKit,优点是实现简单。缺点也很明显,不考虑苹果apns的服务器负载增加的损失。只考虑开发者自己这边,最主要的缺点是通知到来的时间不稳定。既可能在第一次的时间到来,也可能是第二次。如果是对于通知到来时间,有特定要求的程序,那就不能用这种方式,而必须使用PushKit

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

为什么说只有远程通知才是最吼的!(续+1)

watchOS

昨天的为什么说只有远程通知才是最吼的!(续)更新了。增加了新的缺点。这篇文章就是为了解决这个新缺点的。

新缺点

远程通知用久了,你会发现,有时Provider服务器会在你要求发送远程通知的时候回复你错误,说token未注册之类的。如果你之前能成功发送远程通知,那么这个错误指的不是token未注册,而是过期了。是的,远程通知注册之后的token,可能过期。

哪些情形token会过期?

  1. 一种是token用了一段时间会过期。这个一段时间是多久,苹果并未说明,我这里测试平均至少是一天以上。
  2. 当你的程序被卸载并重新安装。这个卸载指的是从iOS上卸载。因为watchOS不能直接注册远程通知,因此从watchOS卸载,再重新安装无影响。

解决方案

知道问题的原理,就可以开始解决了。我的思路是,既然远程通知的token会过期,过期的表现在app看来就是通知不来。那么可以通过计算通知的间隔来决定何时重新注册通知。比如,我的通知设定在整点和整点20分通知。且整点的通知了,之后的20分钟的那么就不通知了。因此,不来的情形是这样:

上次通知时间 没来的通知 没来的通知 间隔时长
9:00 10:00 10:20 80分钟
9:20 10:00 10:20 60分钟


可以看出来,理论上只要连续80分钟没有通知过来,就可以认为通知出了问题。实际上,考虑远程通知的延迟,可以将间隔时长设定为90分钟。即连续时长90分钟没有通知过来,就通过session给iOS发消息,让它重新注册通知。

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

为什么说只有远程通知才是最吼的!

watchOS

你写了一个iOS应用,不满足,还要写一个watchOS应用。你写好了watchOS的应用,不满足,还要写个表盘小部件。终于全都写好了。实际运行时,你发现,小部件它竟然不按照你设计的更新。好的欢迎你入坑watchOS,下面咱们来讨论一下究竟发生了什么。

伪实时的表盘小部件

做为一个watchOS的用户是幸福的,看着表盘上那些显示最新数据的各个小部件,一种掌控感油然而生——"I am the King of the world!"

但是,作为一名watchOS开发者,你必须知道,表盘小部件是伪实时的。苹果限制你实时地更新它。苹果设想的小部件的数据是这样的,你提前就知道某个时间会发生什么,然后在小部件初始化时,一次告知watchOS,watchOS会在相应的时间到来时,更改表盘的内容,这样,在用户看来,表盘就是实时变化的了。

可是如果实际内容发生了变化怎么办?根据变化的类型,你可以选择重新加载时间片,或者扩展时间片。二者的区别是前者作废旧数据,而后者是在旧数据的基础上添加新数据。

这么做就可以了吗?

怎么会?苹果会让你那么舒服?上面提到的两个操作,你的程序在计算时,苹果会拿着小本本记录你消耗了多少时间。一旦消耗时间过多,超过了苹果允许的范围,那么苹果就会立即终止你的计算。并且你再申请,也会无视你。直到,直到第二天的到来。

为什么苹果要这么做?

一切为了电池!Apple Watch的电池小,一旦没电,大家全完蛋。

因此,苹果认为不好用的小部件,也比没有电强太多。而重新计算小部件的行为,被视作是耗电行为。需要加以限制。

如何应对?

在讨论如何应对之前,我们先看看watchOS面对的复杂情况。说复杂,是因为相比于iOS,watchOS经常会遇到资源竞争的情形。

我们先假设你的苹果手表开启了多个表盘,你制作的程序叫A,它支持小部件,并且在这些表盘中,有的表盘添加了A的小部件,有的表盘则没有。并且,每个表盘上A的小部件数量至多只有一个。那么……

手表启动篇

当苹果手表启动后,会自动运行dock中的程序,以及当前表盘上的小部件。此时,就有可能对于相同的资源同时进行访问,从而产生问题。要解决这个问题,可以采用的最优策略是,不在程序的主界面运行可能资源占用冲突的代码,而只让表盘小部件运行。

为什么说这个是最优的策略?因为表盘的小部件运行苹果会记录时间,如果你采用信号量的方式,那么主程序独占资源运行的时间,会被一同记录到表盘的小部件运行的时间(虽然这时小部件没有运行,而是在等待资源独占结束),这样就会产生超时错误。

当然,你也可以使用Timer来延迟主程序的运行,但是这个延迟运行的延迟时间不好掌握。太短了不起作用,会产生同样的问题。太长的话,如果用户此时打开了主程序,会发现界面一直不动,这样程序用起来也还是有问题。

表盘切换篇

当你通过滑动屏幕切换手表的表盘时,如果新表盘有相应的小部件。那么系统会根据情况刷新小部件。因此,你需要根据数据的新鲜程度来决定是否要刷新小部件,还是沿用旧的数据。

另外,如果该表盘没有小部件,那么就没有后台刷新小部件的必要。这样可以节省电量。

后台任务的种类(表盘小部件、Dock以及远程通知)

如果你想要定期后台更新程序的内容,那么有三种情况可以做到。

  1. 程序在当前的表盘有相应的小部件。这样可以保证你的程序每小时至少可以后台运行一次。至多运行的次数,在于当前表盘有多少个小部件。如果其它小部件有没使用自己的次数的,那么你可以占用它们节省的次数。
  2. 如果你的程序没有小部件,又需要后台刷新,那么你的程序需要在Dock中存在。在Dock中保留的程序每小时至少可以运行一次。至多可以运行10次,这取决于Dock中其它程序是否使用了它的次数,因为是大家共用这10次。
    1. watchOS 3.x比较简单,直接选择固定该程序在Dock就可以。
    2. 但是到了watchOS 4.0,苹果画蛇添足的增加了两种模式的Dock,并且将新模式作为了默认。新模式被称为近期(recent),即你最近运行的程序被固定在Dock上,新运行的会逐渐顶掉旧的,但是这个没有鸟用。因为你近期运行的程序很可能不需要后台刷新,而需要的则可能被挤到外面去了。
    3. 这时,就要引导用户开启Dock的另外一个模式,最爱模式(favorite)。这个其实就是watchOS 3.x中原本的模式。用户可以选择哪些程序常驻Dock。
  3. 远程通知。远程通知是通过苹果推送服务器,将远程消息推送到用户的iPhone,iPhone在30秒内处理完成后,将结果发到苹果手表上。苹果手表接到结果,再进一步处理,之后刷新表盘小部件或向用户发出通知。

各种后台任务运行时的区别

  • 小部件和Dock中运行后台刷新的方式,属于本地刷新,可以通过WKExtension.scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:)的方法来进行安排。时间到了,系统会调用WKExtensionDelegate.handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>)方法,执行后台任务。
    • 优点:
      • 本地操作。实现简单。
      • 可以直接运行异步的调用。异步调用结束时,调用task.setTaskCompletedWithSnapshot(Bool)即可
    • 缺点:
      • 时间不稳定。根据苹果的文档,虽然你可以指定后台任务运行的时间。但是这个时间watchOS只是用来参考,系统会在它认为适当的时机进行调用。可能比设定的时间早,也可能晚。实际运行中,我发现只可能晚,还没发现有早于设定时间的情形。
      • 计算量存在限额。苹果很奇葩的规定了计算量的限制,计算量是一个很抽象的概念。不同于计算时间,计算量是一个确定的量,但是苹果并未公开这个量具体是多少。只是说当后台任务运行时,它会记录,如果超出了,就会终止。直到重新分得配额。这个不是以天为单位的,而是动态的。如果你的计算量较大。就很可能出现表盘不更新的问题。
  • 远程通知的方式,属于远程刷新,需要配置调用苹果的推送服务器。远程通知到来时,必须先经过iOS,调用UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:),然后调用WCSession.transferCurrentComplicationUserInfo(_:)给苹果手表发送信息。最终手表端会调用WCSessionDelegate.session(_:didReceiveUserInfo:)函数,执行最终的任务。
    • 优点:
      • 准时。苹果的远程推送,只要不是批量推送,而是推动到指定设备或指定账户的设备。还是很准时的。延迟在几秒到几分钟。
      • 计算量充裕。远程通知在iOS端最多可以后台运行30秒。因为iPhone速度远快于Apple Watch,因此有充裕的计算量来进行计算。此外,在传递到watchOS后,相应地还可以继续进行后台计算。这个也没有文档说有计算量的限制。
    • 缺点:
      • 次数限制。虽然远程通知没有计算量的限制,但是由于规定远程通知必须先经过iOS,然后由iOS唤醒watchOS,即WCSession.transferCurrentComplicationUserInfo(_:)这个函数,这个函数每个程序每天有50次的调用限制。超出限制后,调用会自动降级到WCSession.transferUserInfo(_:)相同的待遇,即必须手动打开Apple Watch上的对应程序,才会接收相应的数据。
      • 连接性问题。不同于本地任务,是在手表本地运行的。远程通知必须有网络才能收到。同时,由于通知先传到iOS,处理后再传到Apple Watch。如果此时手表与手机距离较远,会导致相应的数据在iOS排队。而这个队列在手表与手机重新连接后不会主动发送。而是需要手工开启iOS上相应的程序才会发送。我认为这个行为属于bug,已经向苹果进行了提交。
      • WCSessionDelegate.session(_:didReceiveUserInfo:)不能直接处理异步程序。如果你的代码里包含异步程序,那么你就需要增加额外的代码。比如使用信号量来限制函数不要一下跑完。如果函数执行完毕了,很有可能异步程序还没处理完,手表程序就被系统睡眠了。
      • 实现复杂。远程通知不仅需要申请通知权限,还可能需要使用第三方的推送服务。以及申请http的特定域名的解禁。因为苹果目前默认只能运行https的域名,还必须是特定加密等级之上的。
      • watchOS 3.x存在bug。早期的watchOS 3.x会在iOS端调用WCSession.transferCurrentComplicationUserInfo(_:)之后,自动刷新表盘小部件,然后再调用WCSessionDelegate.session(_:didReceiveUserInfo:)。这个是有很大的问题的。因为表盘小部件在数据到来之前就刷新了。而且是每次都刷新,而不是先判断是否有必要进行刷新。大概半年前,我向苹果提交了这个问题的的错误报告。目前4.0解决了这个问题,不会直接刷新表盘,而是由开发者来判断是否需要刷新表盘。我不清楚3.x的后续版本是否也解决了这个问题。有需要的可以自己测试一下。

虽然有以上的缺点,但是如果你的计算量太大,本地后台任务经常超时,那么远程通知是唯一的选择。

多个小部件

如果你的表盘有你的程序的多个小部件,那么小部件之间也会产生资源冲突的问题。但是这个情况一般很少见。因为除非用于测试,用户一般不会在屏幕上放两个一样的小部件。因此,这个问题可以暂时忽略。

系列文章

为什么说只有远程通知才是最吼的!
为什么说只有远程通知才是最吼的!(续)
为什么说只有远程通知才是最吼的!(续+1)

Twitter首次授权,三步这么走

iOSmacOS

three steps

初步结论

发送部分

  • methodPOST时,postContent为nil或需要上传的数据。
    • 例外:申请用户授权的第一步时,oauth_callback是作为header的一部分,而不是postContent
  • methodGET时,会需要在baseURL后连接GET的信息urlGetParameter,形成新的fullURL
  • oauth_signature的计算,是将methodbaseURL和所有要发送的信息连起来,并计算校验值。其中,除methodbaseURL之外的所有的键值对,需要按照键的字母顺序进行排列。
  • oauth_signature的计算,signing_key由两部分构成,consumer_secretoauth_token_secret两部分合成,中间用&连接。
    • 例外:申请用户授权的第一步时,由于没有下发的oauth_token_secretsigning_keyconsumer_secret+&
  • header永远使用Authorization字段,该字段使用的键为七个:oauth_consumer_keyoauth_nonceoauth_signatureoauth_signature_methodoauth_timestampoauth_tokenoauth_version
    • 例外:申请用户授权的第一步时,此时还没有服务器下发的oauth_token。但这时要把oauth_callback加入其中。
  • 例外:虽然oauth_signature的计算方法写的是使用baseURL,但是我发现,当HttpMethodGET时,既可以使用baseURL进行计算,也可以使用fullURL进行计算。当使用fullURL时,需要把GET的参数项对应的键值对,从oauth_signature的计算中移除。

接收部分

  • 接收成功的标志是successStatusCode=200。
  • 接收的内容是一组由预定的键组成的键值对。可以认为,键值对与baseURL是对应的关系。
  • 例外:申请用户授权的第二步时,会用到第一步里的跳转链接oauth_callback
  • 例外:申请用户授权的第二步时,由于用户需要用户授权,然后跳转,这一步,是没有返回值的。跳转之后的连接需要开发者手动拦截。
  • 例外:类似发推功能的API这种,我们只需要确认成功发推,并不关注返回值。此时可以设定返回值的键为空,不对其进行处理。

具体实施

第一步:Obtaining a request token

  1. 采用URLSessionDataTask链接,并处理传来的数据
  2. 打开WebViewViewController

第二步:Redirecting the user

  1. 通过内嵌的网页处理URLRequest
  2. 用户登录并授权后,Twitter会跳转到第一步中设定的会掉页面
  3. 通过WKNavigationDelegatefunc webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {,截获该跳转,获得数据。
  4. 此时需要验证本次收到的oauth_token与第一步收到的相同

第三步:Converting the request token to an access token

  1. 采用URLSessionDataTask链接,将第二步获得的oauth_verifier,发往服务器进行验证,获得最终的tokens,并保存。
  2. 授权完成。

Twitter REST API使用通用流程

iOSmacOS

步骤

  1. 申请建立新程序(账号需要绑定手机)
  2. 测试OAuth 1.0

各个变量的含义

原文地址:
POST oauth/request_token
Creating a signature

参数 含义
oauth_consumer_key 申请的程序的key
oauth_nonce base64编码的32字节的随机数据。Twitter使用它来查验信息是否属于重复提交
oauth_signature 所有提交的信息的校验码,用于检验内容是否被篡改1
oauth_signature_method 校验码的计算方法,固定为HMAC-SHA1
oauth_timestamp Unix相对于GMT 1970年1月1日的秒数。
oauth_token 代表当前用户的token,首次使用时需要向Twitter申请
oauth_version oauth的版本,目前总是1.0
Consumer secret 你申请App时获得的另外一段文本2
OAuth token secret 调用API后从Twitter网站获得的当前用户的标志
Signing key 计算校验码时的key,由Consumer secret和OAuth token secret加工而成
status 用户发送的tweet的正文

创建校验码的方法

原文地址:
Creating a signature

写在前面

**一定要记住:只有上面原文的例子是真正可用的。**不过这个例子也有一些问题,后面我会提到。

按照流程我们一般会最先到POST oauth/request_token页面去。因为我们不申请授权的话,什么事都做不了。但这里是Twitter官方文档的第一个坑。用来计算oauth_signature需要的key的一部分是Consumer secret,而这个页面根本没有提供它。因此,这个例子里的值是没法计算的。

更坑的是,由于Consumer secretConsumer Key非常像,而后者就是oauth_consumer_key,这个值在这个页面有提供。因此,初学者如我,就很容易把oauth_consumer_key作为Consumer secret来计算oauth_signature。得到的结果自然与例子不同。我在这里浪费了很多的时间。

另外一个页面Authorizing a request也是同样的问题,没有提供Consumer secret,因此也是无法计算的。

结论:强烈建议你在算法设计完成时,使用Creating a signature这个页面提供的例子来检验你的算法是否正确。如果正确了,那么再进行下一步。

需要计算的量

在调用一次API时,需要有很多参数,有一些参数的值是可以直接获得的,比如oauth的版本,signature的校验方式,都是固定的,每次都一样。但也有一些参数需要计算,简单的timestamp的就不提了,下面提几个相对复杂的。

oauth_nonce

oauth_nonce是一个应该每次都不一样的字符串,Twitter通过这个值来验证客户端是否发送了重复的请求。按照官方的说法,这个字符串的计算过程是取一段32个字节的随机数据,然后将这段数据采用base64编码的方式输出为字符串,之后过滤掉其中非词的字符。

and stripping out all non-word characters

这里的非词指的是大小写字母和数字之外的字符。^[a-z|A-Z|0-9]

实现这个的算法有很多,但是有一点我很疑惑。因为官方文档里的几个例子,字符都是42个,但是我按照算法说明生成的,一般都是43个。只有把32个字节,改成31个字节,得到的才是42个。

先说结论,这个字符串是42还是43其实无所谓。后来我的程序确定能通过之后,我发现,有时生成的字符串是45个。也就是说,其实这个字符串,本质上来讲,只要每次不一样就可以,长度其实没有限制。

附上我的算法:

private static func calculateOauthNonce() -> String {
    let uuidString = UUID().uuidString
    let randomStringIn32Bytes = uuidString.substring(to: uuidString.index(uuidString.startIndex, offsetBy: 32))
    let dataOfRandomStringIn32Bytes = randomStringIn32Bytes.data(using: .utf8)!
    let base64EncodedStringOfRandomStringIn32Bytes = dataOfRandomStringIn32Bytes.base64EncodedString()
    
    let modifiedBase64EncodedString = base64EncodedStringOfRandomStringIn32Bytes.unicodeScalars
        .filter {
            CharacterSet.alphanumerics.contains($0)
        }
        .reduce("", { (result, unicodeScalar) -> String in
            let character = Character(unicodeScalar)
            return result + String(character)
        })
    
    return modifiedBase64EncodedString
}

percent encode

在计算headersignature等处,需要使用到percent encode。百分比化,即把不在范围内的字符,转化为带%号的形式。按照官方的规则percent encode的计算方法如下:

let nonePercentedCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
private func percentedString(_ source:String) -> String {
    return source.addingPercentEncoding(withAllowedCharacters: nonePercentedCharacterSet)!
}

signature base string

signature base string是计算signature的中间步骤。这里有一点要注意,即键值对的key要按照字母顺序排列。这是为了计算校验值的唯一。

这里有一个坑,官方例子提供的值是
POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521

它去掉百分号实际上是

let s2 = "POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521"

print(s2.removingPercentEncoding!)

// POST&https://api.twitter.com/1/statuses/update.json&include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21

可以看到,url实际是https://api.twitter.com/1/statuses/update.json,但是官方文档中写url是https://api.twitter.com/1.1/statuses/update.json。这里一个是1,一个是1.1,你按照文档的url来算,那么signature肯定就和官方提供的tnnArxj06cWHq44gCs1OSKk/jLY=不一样。

signing key

标准的signing key由两部分组成,百分比化的Consumer secret与百分比化的OAuth token secret,二者按照先后顺序用&链接,需要注意两点:

  1. Consumer secret而不是Consumer Key
  2. &不需要百分比化

当首次申请时,由于没有OAuth token secret,此时的signing key为百分比化的Consumer secret之后加上&

signature

这里直接调用加密的函数就可以,值得说的部分是,官方这里要求使用HMAC-SHA1,但是输出值不是普通的字符串,而是要转成base64编码的字符串输出。网上能找到的例子,一般都只是普通的字符串输出,这里提供我的算法,主要是增加了base64编码的输出,以及一些重构。

import Foundation

enum CryptoAlgorithm {
    case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
    
    var HMACAlgorithm: CCHmacAlgorithm {
        var result: Int = 0
        switch self {
        case .MD5:
            result = kCCHmacAlgMD5
        case .SHA1:
            result = kCCHmacAlgSHA1
        case .SHA224:
            result = kCCHmacAlgSHA224
        case .SHA256:
            result = kCCHmacAlgSHA256
        case .SHA384:
            result = kCCHmacAlgSHA384
        case .SHA512:
            result = kCCHmacAlgSHA512
        }
        return CCHmacAlgorithm(result)
    }
    
    var digestLength: Int {
        var result: Int32 = 0
        switch self {
        case .MD5:
            result = CC_MD5_DIGEST_LENGTH
        case .SHA1:
            result = CC_SHA1_DIGEST_LENGTH
        case .SHA224:
            result = CC_SHA224_DIGEST_LENGTH
        case .SHA256:
            result = CC_SHA256_DIGEST_LENGTH
        case .SHA384:
            result = CC_SHA384_DIGEST_LENGTH
        case .SHA512:
            result = CC_SHA512_DIGEST_LENGTH
        }
        return Int(result)
    }
}

enum HashStyle {
    case string, base64EncodedString
}

extension String {
    func hmac(algorithm: CryptoAlgorithm, key: String, hashStyle:HashStyle = .base64EncodedString) -> String {
        let str = self.cString(using:.utf8)
        let strLen = self.lengthOfBytes(using:.utf8)
        let digestLen = algorithm.digestLength
        let hashBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: digestLen)
        let keyStr = key.cString(using:.utf8)
        let keyLen = Int(key.lengthOfBytes(using:.utf8))
        
        CCHmac(algorithm.HMACAlgorithm, keyStr!, keyLen, str!, strLen, hashBytes)
        
        let digest:String
        switch hashStyle {
        case .string:
            digest = calculateString(from: hashBytes, length: digestLen)
        case .base64EncodedString:
            digest = calculateBase64EncodedString(from: hashBytes, length: digestLen)
        }
        
        hashBytes.deallocate(capacity: digestLen)
        
        return digest
    }
    
    private func calculateString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let string = (0..<length).reduce("") { (result, index) -> String in
            return result + String(hashBytes[index])
        }
        
        return string
    }
    
    private func calculateBase64EncodedString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let data = Data(bytes:hashBytes, count:length)
        
        return data.base64EncodedString()
    }
}

计算header

原文:Authorizing a request
这个没啥好说的,按照说明就行了。header和计算signature不一样,header里的键值对的顺序可以是任意的。

调用API

全都计算好了,就可以申请调用API了。简单的步骤如下:

let url = URL(string: "https://api.twitter.com/oauth/request_token")!

var request = URLRequest(url: url)
request.httpMethod = "POST"

var tt = Twitter()

request.setValue(tt.postOauthRequestToken.header, forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) {
    data, response, error in
    
    if error != nil {
        print("error=\(error!.localizedDescription))")
        return
    }
    
    print("response = \(response!.description)")
    
    let responseString = String(data: data!, encoding: .utf8)!
    print("responseString = \(responseString)")
}
task.resume()

如果看到输出的response里的代码是200,那么就证明你的程序是没有问题的了。

  1. 创建校验码的方法

  2. 需要注意的事,计算校验码时需要的key的一部分是Consumer Secret而不是Consumer Key,Twitter的部分文档的例子里没有强调这一点,并切没有提供例子对应的Consumer Secret,这导致了那些例子你是没法计算的,也就是说例子根本是无效的。研究它们只能浪费时间。这一点后面还会提到。