普通视图

发现新文章,点击刷新页面。
昨天以前SwiftGG

分析复杂度

作者 SwiftGG
2019年11月6日 00:00

作者:Soroush Khanlou,原文链接,原文日期:2018-12-17
译者:RocZhang;校对:numbbbbbWAMaker;定稿:Pancf

在 Dave Abraham 的 WWDC 演讲 Embracing Algorithms(拥抱算法)中,谈到了要发现通用的算法,并将其提取到通用且可测试的函数中。在这个方向上,我发现一些对集合类型的多次操作可以被聚齐起来,合并成单次操作。并且通常情况下,这些操作被合并之后还能带来性能上的收获。

第一个例子是 Swift 3 添加的一个方法:

// 当你看到:
myArray.filter({ /* some test */ }).first

// 你应该把它改成:
myArray.first(where: { /* some test */ })

这里两种写法的断言描述闭包和操作结果都完全相同,但下面的写法更简短,语义更清晰,而且性能更好。因为它不会进行新数组的分配,也不需要对数组中每一个元素是否能够通过测试都进行判断,只需要找出第一个就好了。

另一个例子是 我帮助添加到 Swift 5 中的 count(where:) 函数:

// 当你看到:
myArray.filter({ /* some test */ }).count

// 你应该把它改成:
myArray.count(where: { /* some test */ })

这又是一个更短、更清晰而且更快的例子。没有额外要被分配的数组,也没有多余的操作。

在我们的一个项目中,有一个通用的范式,需要先将集合进行 sort,随后再进行 prefix 操作。例如下述的示例代码,需要找出前 20 张最新创建的图像:

return images
.sorted(by: { $0.createdAt > $1.createdAt })
.prefix(20)

同样,也可以想象成在排行榜中找到前 5 位得分最高的用户,也需要使用这类范式。

我盯着这段代码直到我的眼睛开始流血,感到这段代码可能存在问题。我首先想到的是分析它的时间复杂度。如果把原始数组的长度用 n 表示,再把最后想要得到的元素的数目用 m 表示,在分析代码之后可以得出,排序的复杂度是 O(nlogn),取前子集合的操作则更快,时间复杂度为 O(1)(取前子集合操作本身最慢时可能会达到 O(m),但对这里我们要处理的数组而言,由于它是可随机访问的集合,因此取前子集合操作能在常数时间中完成)。

这正是让我感到困惑的地方:获取一个序列的最小元素(使用 min() 函数)只需要单次遍历所有元素,或者说时间复杂度为 O(n)。将其所有元素进行完整排序需要的时间复杂度是 O(nlogn)。而从集合中获取 m 个数,当 m 比 n 小时,时间复杂度应该位于它们之间。且当 m 比 n 小非常多时,时间复杂度应该更接近 O(n)。

在我们的例子里,图片的数量会非常大(n 约为 55000),而我们想得到的元素数量却很小(m 为 20)。因此,这里应该存在有优化的空间。我们是否能够优化排序,使其仅排序出前 m 个元素?

答案是肯定的,我们能够在这个方向上进行一些优化。我将此函数命名为 smallest(_:by:),它接收 sortprefix 函数的所有参数,也就是上面提到的 m 和用于排序做比较的闭包:

func smallest(_ m: Int, by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {

首先从排序前 m 个元素开始(因为 m 比序列的总长度要小很多,所以此操作会进行的很快):

var result = self.prefix(m).sorted(by: areInIncreasingOrder)

然后我们再遍历所有剩下的元素:

for element in self.dropFirst(m) {

对集合中剩下的每一个元素,我们需要找到 result 中第一个比它大的项的索引。通过 areInIncreasingOrder 函数,我们把 element 作为第一个参数传入,再把 result 中的元素作为第二个参数传入。

if let insertionIndex = result.index(where: { areInIncreasingOrder(element, $0) }) { // 译者注:此方法在 Swift 4.2 后已更名为 `firstIndex(where:)`

如果能够找到符合条件的索引值,这就表示存在有比我们 result 数组中的元素更小的值。我们把新的值插入到计算出的索引的位置,它便会被正确的排序:

result.insert(element, at: insertionIndex)

再将最后一个元素移除(因为我们只需要 m 个元素):

result.removeLast()

如果没有找到满足条件的索引,我们就可以忽略这个值。最后,当 for 循环完成,便可将 result 返回。

完整的函数如下所示:

extension Sequence {
func smallest(_ m: Int, by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {
var result = self.prefix(m).sorted(by: areInIncreasingOrder)

for e in self.dropFirst(n) {
if let insertionIndex = result.index(where: { areInIncreasingOrder(e, $0) }) {
result.insert(e, at: insertionIndex)
result.removeLast()
}
}

return result
}
}

如果这让你想起了之前在计算机科学中学过的课程,那就再好不过了。实际上这里的算法就类似于选择排序的过程(但它们并非完全相同,因为这里会预先排序一部分元素,而选择排序则不同,是可变序列算法(mutating algorithm))。

这里的时间复杂度分析起来可能会有些困难,但是我们还是可以尝试进行分析。初始部分的排序是 O(mlogm),外层的循环是 O(n)。每次的循环中,会分别调用时间复杂度都为 O(m) 的 index(where:)insert(_:at:)(插入操作的时间复杂度是 O(m) 的原因在于,它可能需要将所有的元素后移,为新元素腾出空间)。因此,总时间复杂度应为 O(mlogm + n * (m + m)),或者说 O(mlogm + 2nm)。常数项被移除后,留下的则是 O(mlogm + nm)。

当 m 比 n 小得多时,m 项会接近于常数,最终我们得到的会是 O(n)。相较于之前的 O(nlogn) 而言,这是一个巨大的改进。对应到之前提到的 55000 张图片的例子,这可能会是多达 5 倍的性能提升。

大体上来说,这里的函数是对 prefix + sort 函数的优化。但我还想要再讨论两处更细小一些的优化。

一处唾手可得的优化是:我们是在 55000 个元素的数组中查找 20 个最小的元素,其中我们检查的大部分(几乎是全部)元素不会落入到最后的 result 数组中。因此我们可以去检查元素是否比 result 数组中的最后一个元素要大,如果是,它就完全可以被跳过。因为当元素比 result 中的最后一个还要大时,再去查找插入的索引就没有任何意义了。

if let last = result.last, areInIncreasingOrder(last, e) { continue }

在测试中,此处增加的判断可以减少线性搜索 result 数组 99.5% 的时间,算法整体上又会加快十倍左右。感谢 Greg Titus 告诉我此处可以优化──之前我完全没有想到这一点。

如果想更近一步的话,还可以做另一处(稍微难实现一些)的优化。此优化基于两处事实:第一,我们使用 index(where:) 来找出应在 result 数组中进行插入的位置;第二,result 数组总是保持有序的。index(where:) 通常情况下是一项线性操作,但如果是在一个已经排好序的数组中进行搜索,我们可以将线性搜索替换成二分搜索。我对此进行了尝试。

为了能够更好的理解这些优化会如何影响算法的性能,Nate Cook 帮助我了解了 Karoy Lorentey 的 Attabench 工具,它能够对这些解决方案进行基准测试。因为截止目前,我们对复杂度的分析都是停留在理论层面的,在真正对代码进行实际测试之前(最理想的情况应该是在真实的设备上),所有的结论都只是有根据的推测。例如,通常来说排序的复杂度为 O(nlogn),但不同的算法处理不同类型的数据时,其表现也会有所不同。具体来说,已经排好序的数据在特定的算法中可能会变得更快或更慢。

Attabench 的执行结果如下:

(我还添加了一个 由 Time Vermuelen 所写的优先队列/堆解决方案,因为有些人好奇它与其他方案比较起来表现如何。)

首先,我对在数组中进行单次搜索比对数组进行完整排序要快的猜测是正确的。尤其是在实际问题中序列可能会很长,排序的性能则会变得更差,但我们的“简单优化”(图中的 “Naive Optimization”)却能保持在常数水平上(Y 轴表示的是单个元素上所花的时间,而不是总时间。这意味着 O(n) 的算法在图中会是一条直线)。

第二,对最后一个元素的检查(图中的 “Last Check”)和二分搜索优化在独立运行时具有几乎完全相同的性能表现(实际上你可能没法看到橘色和黄色的线,因为它们被绿线挡住了),把它们放在一起使用时也是一样。但是由于二分搜索难以编写,甚至更难把它写对,你也可以说把它加上是不值得的。

对我而言,这里传递出的关键信息是测量和优化很难。虽然分析复杂度这件事听起来有些学术:“我什么时候会在自己的职业生涯上用到这个?” 有人会问。但理解你的算法的时间和空间复杂度能够帮助你决定向哪个方向进行探索。在这个例子中,理解排序的时间复杂度引导我们对问题产生了概括性的认知,得到成果。最后,通过使用各种数据进行的进一步的基准测试与分析,能告诉我们代码在生产环境下将如何运作的最准确的信息。

下一次再看到 sort 后面紧跟着一个 prefix 时,不妨考虑将它替换成 smallest(_: by:)

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

每一点进步都是快乐:无处不在的扩展

作者 SwiftGG
2019年10月29日 00:00

作者:Russ Bishop,原文链接,原文日期:2018-11-08
译者:俊东;校对:numbbbbbWAMaker;定稿:Pancf

这篇文章记录了我所收获的小惊喜。在 Swift 中写扩展让人感觉非常自然。

我认为 UnsafeMutableRawBufferPointer.baseAddress 是可选项这回事非常不合理。在实践中它会使代码变得丑陋。我也不喜欢在分配时指定对齐方式;在大多数平台上,合理的默认值都是 Int.bitWidth / 8

通过扩展,我们可以很容易地解决这些问题。这样的解决方案能像标准库一样自然地使用。

首先,我们需要在调试版本中进行简单的健全性检查,以确保不会产生无意义的对齐计算。这里提一个有关正整数的小技巧:一个 2 的 n 次幂数只有一个比特位是有值的。减去 1 时就是把后面的所有比特位设置为 1,如 8(0b1000)- 1 得到 7(0b0111)。这两个数字没有共同的位,因此按位取与应该产生零。由于这规律在零上无效,所以需要单独检查。

extension BinaryInteger {
var isPositivePowerOf2: Bool {
@inline(__always)
get {
return (self & (self - 1)) == 0 && self != 0
}
}
}

让 allocate 方法默认使用自然整数宽度对齐。设置对齐参数可能有点多余,不过它几乎能处理我们想要存储在缓冲区中的任何数据。虽然断言仅在调试环境中有效,但这已经够应付我们的使用;已知 Swift 支持的平台上这个断言都会是 true。

extension UnsafeMutableRawBufferPointer {
static func allocate(byteCount: Int) -> UnsafeMutableRawBufferPointer {
let alignment = Int.bitWidth / 8
assert(alignment.isPositivePowerOf2, "expected power of two")
return self.allocate(byteCount: byteCount, alignment: alignment)
}
}

最后再提一个点,我们可以添加一个隐式强制解包的 base 属性。

extension UnsafeMutableRawBufferPointer {
var base: UnsafeMutableRawPointer {
return baseAddress!
}
}
extension UnsafeRawBufferPointer {
var base: UnsafeRawPointer {
return baseAddress!
}
}

一切如此简单。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

图像优化

作者 SwiftGG
2019年10月9日 00:00

作者:Jordan Morgan,原文链接,原文日期:2018-12-11
译者:Nemocdz;校对:numbbbbbWAMaker;定稿:Pancf


俗话说得好,最好的相机是你身边的那个。那么毫无疑问 - iPhone 可以说是这个星球最重要的的相机。而这在业界也已经达成共识。

在度假?不偷偷拍几张记录在你的 Instagram 故事里?不存在的。

出现爆炸新闻?查看 Twitter,就可以知道是哪些媒体正在报道,通过他们揭露事件的实时照片。

等等……

正因为图像在平台上无处不在,如果管理不当,很容易出现性能和内存问题。稍微了解下 UIKit,搞清楚它处理图像的机制,可以节省大量时间,避免做无用功。

理论知识

快问快答 - 这是一张我漂亮(且时髦)女儿的照片,大小为 266KB,在一个 iOS 应用中展示它需要多少内存?

剧透警告 - 答案不是 266KB,也不是 2.66MB,而是接近 14MB。

为啥呢?

iOS 实际上是从一幅图像的尺寸计算它占用的内存 - 实际的文件大小会比这小很多。这张照片的尺寸是 1718 像素宽和 2048 像素高。假设每个像素会消耗我们 4 个比特:

1718 * 2048 * 4 / 1000000 = 14.07 MB 占用

假设你有一个用户列表 table view,并且在每一行左边使用常见的圆角头像来展示他们的照片。如果你认为这些图像会像洁食(犹太人的食品,比喻事情完美无瑕)一样,每个都被类似 ImageOptim 的工具压缩过,那可就大错特错了。即使每个头像的大小只有 256x256,也会占用相当一部分内存。

渲染流程

综上所述 - 了解幕后原理是值得的。当你加载一张图片时,会执行以下三个步骤:

1)加载 - iOS 获取压缩的图像并加载到 266KB 的内存(在我们这个例子中)。这一步没啥问题。

2)解码 - 这时,iOS 获取图像并转换成 GPU 能读取和理解的方式。这里会解压图片,像上面提到那样占用 14MB。

3)渲染 - 顾名思义,图像数据已经准备好以任意方式渲染。即使只是在一个 60x60pt 的 image view 中。

解码阶段是消耗最大的。在这个阶段,iOS 会创建一块缓冲区 - 具体来说是一块图像缓冲区,也就是图像在内存中的表示。这解释了为啥内存占用大小和图像尺寸有关,而不是文件大小。因此也可以理解,为什么在处理图片时,尺寸如此重要。

具体到 UIImage,当我们传入从网络或者其它来源读取的图像数据时,它会将数据解码到缓冲区,但不会考虑数据的编码方式(比如 PNG 或者 JPG)。然而,缓冲区实际上会保存到 UIImage 中。由于渲染不是一瞬间的操作,UIImage 会执行一次解码操作,然后一直保留图像缓冲区。

接着往下说 - 任何 iOS 应用中都有一整块的帧缓冲区。它会保存内容的渲染结果,也就是你在屏幕上看到的东西。每个 iOS 设备负责显示的硬件都用这里面单个像素信息逐个点亮物理屏幕上合适的像素点。

处理速度非常重要。为了达到黄油般顺滑的每秒 60 帧滑动,在信息发生变化时(比如给一个 image view 赋值一幅图像),帧缓冲区需要让 UIKit 渲染 app 的 window 以及它里面所有层级的子视图。一旦延迟,就会丢帧。

觉得 1/60 秒太短不够用?Pro Motion 设备已经将上限拉到了 1/120 秒。

尺寸正是问题所在

我们可以很简单地将这个过程和内存的消耗可视化。我创建了一个简单的应用,可以在一个 image view 上展示需要的图像,这里用的是我女儿的照片:

let filePath = Bundle.main.path(forResource:"baylor", ofType: "jpg")!
let url = NSURL(fileURLWithPath: filePath)
let fileImage = UIImage(contentsOfFile: filePath)

// Image view
let imageView = UIImageView(image: fileImage)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true

view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true

实践中请注意强制解包。这里只是一个简单的场景。

完成之后就会是这个样子:

虽然展示图片的 image view 尺寸很小,但是用 LLDB 就可以看到图像的真正尺寸。

<UIImage: 0x600003d41a40>, {1718, 2048}

需要注意的是 - 这里的单位是。所以当我在 3x 或 2x 设备时,可能还需要额外乘上这个数字。我们可以用 vmmap 来确认这张图像是否占用了 14 MB:

vmmap --summary baylor.memgraph

一部分输出(省略一些内容以便展示):

Physical footprint:         69.5M
Physical footprint (peak): 69.7M

我们看到这个数字接近 70MB,这可以作为基准来确认针对性优化的成果。如果我们用 grep 命令查找 Image IO,或许会看到一部分图像消耗:

vmmap --summary baylor.memgraph | grep "Image IO"

Image IO 13.4M 13.4M 13.4M 0K 0K 0K 0K 2

啊哈 - 这里有大约 14MB 的脏内存,和我们前面的估算一致。如果你不清楚每一列表示什么,可以看下面这个截图:

通过这个例子可以清楚地看到,哪怕展示在 300x400 image view 中,图像也需要完整的内存消耗。图像尺寸很重要,但是尺寸并不是唯一的问题。

色彩空间

能确定的是,有一部分内存消耗来源于另一个重要因素 - 色彩空间。在上面的例子中,我们的计算基于以下假设 - 图像使用 sRGB 格式,但大部分 iPhone 不符合这种情况。sRGB 每个像素有 4 个字节,分别表示红、蓝、绿、透明度。

如果你用支持宽色域的设备进行拍摄(比如 iPhone 8+ 或 iPhone X),那么内存消耗将变成两倍,反之亦然。Metal 会用仅有一个 8 位透明通道的 Alpha 8 格式。

这里有很多可以把控和值得思考的地方。这也是为什么你应该用 UIGraphicsImageRenderer 代替 UIGraphicsBeginImageContextWithOptions 的原因之一。后者总是会使用 sRGB,因此无法使用宽色域,也无法在不需要的时候节省空间。在 iOS 12 中,UIGraphicsImageRenderer 会为你做正确的选择。

不要忘了,很多图像并不是真正的摄影作品,只是一些绘图操作。如果你错过了我最近的文章,可以再阅读一遍下面的内容:

let circleSize = CGSize(width: 60, height: 60)

UIGraphicsBeginImageContextWithOptions(circleSize, true, 0)

// Draw a circle
let ctx = UIGraphicsGetCurrentContext()!
UIColor.red.setFill()
ctx.setFillColor(UIColor.red.cgColor)
ctx.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.drawPath(using: .fill)

let circleImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

上面的圆形图像用的是每个像素 4 个字节的格式。如果换用 UIGraphicsImageRenderer,通过渲染器自动选择正确的格式,让每个像素使用 1 个字节,可以节省高达 75% 的内存:

let circleSize = CGSize(width: 60, height: 60)
let renderer = UIGraphicsImageRenderer(bounds: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))

let circleImage = renderer.image{ ctx in
UIColor.red.setFill()
ctx.cgContext.setFillColor(UIColor.red.cgColor)
ctx.cgContext.addEllipse(in: CGRect(x: 0, y: 0, width: circleSize.width, height: circleSize.height))
ctx.cgContext.drawPath(using: .fill)
}

缩小图片 vs 向下采样

现在我们从简单的绘图场景回到现实世界 - 许多图片其实并不是艺术作品,只是自拍或者风景照。

因此有些人可能会假设(并且确实相信)通过 UIImage 简单地缩小图片就够了。但我们前面已经解释过,缩小尺寸并不管用。而且根据 Apple 工程师 kyle Howarth 的说法,由于内部坐标转换的原因,缩小图片的优化效果并不太好。

UIImage 导致性能问题的根本原因,我们在渲染流程里已经讲过,它会解压原始图像到内存中。理想情况下,我们需要一个方法来减少图像缓冲区的尺寸。

庆幸的是,我们可以修改图像尺寸,来减少内存占用。很多人以为图像会自动执行这类优化,但实际上并没有。

让我们尝试用底层的 API 来对它进行向下采样:

let imageSource = CGImageSourceCreateWithURL(url, nil)!
let options: [NSString:Any] = [kCGImageSourceThumbnailMaxPixelSize:400,
kCGImageSourceCreateThumbnailFromImageAlways:true]

if let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) {
let imageView = UIImageView(image: UIImage(cgImage: scaledImage))

imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 400).isActive = true

view.addSubview(imageView)
imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
}

通过这种取巧的展示方法,会获得和以前完全相同的结果。不过在这里,我们使用了 CGImageSourceCreateThumbnailAtIndex(),而不是直接将原始图片放进 image view。再次使用 vmmap 来确认优化是否有回报(同样,省略部分内容以便展示):

vmmap -summary baylorOptimized.memgraph

Physical footprint: 56.3M
Physical footprint (peak): 56.7M

效果很明显。之前是 69.5M,现在是 56.3M,节省了 13.2M。这个节省相当大,几乎和图片本身一样大。

更进一步,你可以在自己的案例中尝试更多可能的选项来进行优化。在 WWDC 18 的 Session 219,“Images and Graphics Best Practices“中,苹果工程师 Kyle Sluder 展示了一种有趣的方式,通过 kCGImageSourceShouldCacheImmediately 标志位来控制解码时机,:

func downsampleImage(at URL:NSURL, maxSize:Float) -> UIImage
{
let sourceOptions = [kCGImageSourceShouldCache:false] as CFDictionary
let source = CGImageSourceCreateWithURL(URL as CFURL, sourceOptions)!
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways:true,
kCGImageSourceThumbnailMaxPixelSize:maxSize
kCGImageSourceShouldCacheImmediately:true,
kCGImageSourceCreateThumbnailWithTransform:true,
] as CFDictionary

let downsampledImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions)!

return UIImage(cgImage: downsampledImage)
}

这里 Core Graphics 不会开始图片解码,直到你请求缩略图。另外要注意的是,两个例子都传入了 kCGImageSourceCreateThumbnailMaxPixelSize,如果不这样做,就会获得和原图同样尺寸的缩略图。根据文档所示:

“…如果没指定最大尺寸,返回的缩略图将会是完整图像的尺寸,这可能并不是你想要的。”

所以上面发生了什么?简而言之,我们将缩放的结果放入缩略图中,从而创建的是比之前小很多的图像解码缓冲区。回顾之前提到的渲染流程,在第一个环节(加载)中,我们给 UIImage 传入的缓冲区是需要绘制的图片尺寸,不是图片的真实尺寸。

如何用一句话总结本文?想办法对图像进行向下采样,而不是使用 UIImage 去缩小尺寸。

附赠内容

除了向下采样,我自己还经常使用 iOS 11 引入的 预加载 API。请记住,我们是在解码图像,哪怕是放在 Cell 展示之前执行,也会消耗大量 CPU 资源。

如果应用持续耗电,iOS 可以优化电量消耗。但是我们做的向下采样一般不会持续执行,所以最好在一个队列中执行采样操作。与此同时,你的解码过程也实现了后台执行,一石多鸟。

做好准备,下面即将为您呈现的是——我自己业余项目里的 Objective-C 代码示例:

// 不要用全局异步队列,使用你自己的队列,从而避免潜在的线程爆炸问题
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths
{
if (self.downsampledImage != nil ||
self.listItem.mediaAssetData == nil) return;

NSIndexPath *mediaIndexPath = [NSIndexPath indexPathForRow:0
inSection:SECTION_MEDIA];
if ([indexPaths containsObject:mediaIndexPath])
{
CGFloat scale = tableView.traitCollection.displayScale;
CGFloat maxPixelSize = (tableView.width - SSSpacingJumboMargin) * scale;

dispatch_async(self.downsampleQueue, ^{
// Downsample
self.downsampledImage = [UIImage downsampledImageFromData:self.listItem.mediaAssetData
scale:scale
maxPixelSize:maxPixelSize];

dispatch_async(dispatch_get_main_queue(), ^ {
self.listItem.downsampledMediaImage = self.downsampledImage;
});
});
}
}

建议使用 asset catalog 来管理原始图像资源,它已经实现了缓冲区优化(以及更多功能)。

想成为内存和图像处理专家?不要错过 WWDC 18 这些信息量巨大的 session:

总结

学无止境。如果选择了编程,你就必须每小时跑一万英里才能跟得上这个领域创新和变化的步伐……换句话说,一定会有很多你根本不知道的 API、框架、模式或者优化技巧。

在图像领域也是如此。大多数时候,你初始化一个了大小合适的 UIImageView 就不管了。我当然知道摩尔定律。现在手机确实很快,内存也很大,但是你要知道 - 将人类送上月球的计算机只有不到 100KB 内存。

长期和魔鬼共舞(译者注:比喻不管内存问题),它总有露出獠牙的那天。等到一张自拍就占掉 1G 内存的时候,后悔也来不及了。希望上述的知识和技术能帮你节省一些 debug 时间。

下次再见 ✌️。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

宏定义与可选括号

作者 SwiftGG
2019年9月27日 00:00

作者:Mike Ash,原文链接,原文日期:2015-03-20
译者:俊东;校对:numbbbbbNemocdz;定稿:Pancf

前几天我遇到了一个有趣的问题:如何编写一个 C 语言预处理器的宏,删除包围实参的括号?

今天的文章,将为大家分享我的解决方案。

起源

C 语言预处理器是一个相当盲目的文本替换引擎,它并不理解 C 代码,更不用说 Objective-C 了。它的工作原理还算不错,可以应付大部分情况,但偶尔也会出现判断失误。

这里举个典型的例子:

XCTAssertEqualObjects(someArray, @[ @"one", @"two" ], @"Array is not as expected");

这会无法编译,并且会出现非常古怪的错误提示。预处理器查找分隔宏参数的逗号时,没能将数组结构 @ [...] 中的东西理解为一个单一的元素。结果代码尝试比较 someArray@[@"one"。断言失败消息 @"two"]@"Array is not as expected" 是另外的实参。这些半成品部分用于 XCTAssertEqualObjects 的宏扩展中,生成的代码当然错得离谱。

要解决这个问题也很容易:添加括号就行。预编译器不能识别 [],但它确实知道 () 并且能够理解应该忽略里面的逗号。下面的代码就能正常运行:

XCTAssertEqualObjects(someArray, (@[ @"one", @"two" ]), @"Array is not as expected");

在 C 语言的许多场景下,你添加多余的括号也不会有任何区别。宏扩展开之后,生成的代码虽然在数组文字周围有括号,但没有异常。你可以写搞笑的多层括号表达式,编译器会愉快地帮你解析到最里面一层:

NSLog(@"%d",((((((((((42)))))))))));

甚至将 NSLog 这样处理也行:

((((((((((NSLog))))))))))(@"%d",42);

在 C 中有一个地方你不能随意添加括号:类型(types)。例如:

int f(void); // 合法
(int) f(void); // 不合法

什么时候会发生这种情况呢?这种情况并不常见,但如果你有一个使用类型的宏,并且类型包含的逗号不在括号内,则会出现这种情况。宏可以做很多事情,当一个类型遵循多个协议时,在 Objective-C 中可能出现一些类型带有未加括号的逗号;当使用带有多个模板参数的模板化类型时,在 C++ 中也可能出现。举个例子,这有一个简单的宏,创建从字典中提供静态类型值的 getter

#define GETTER(type,name) \
- (type)name { \
return [_dictionary objectForKey: @#name]; \
}

你能这样使用它:

@implementation SomeClass {
NSDictionary *_dictionary;
}

GETTER(NSView *,view)
GETTER(NSString *,name)
GETTER(id<NSCopying>,someCopyableThing)

到目前为止没问题。现在假设我们想要创建一个遵循两个协议的类型:

GETTER(id<NSCopying,NSCoding>,someCopyableAndCodeableThing)

哎呀!宏不起作用了。而且添加括号也无济于事:

GETTER((id<NSCopying,NSCoding>),someCopyableAndCodeableThing)

这会产生非法代码。这时我们需要一个删除可选括号的 UNPAREN 宏。将 GETTER 宏重写:

#define GETTER(type,name) \
- (UNPAREN(type))name { \
return [_dictionary objectForKey: @#name]; \
}

我们该怎么做呢?

必须的括号

删除括号很容易:

#define UNPAREN(...) __VA_ARGS__
#define GETTER(type,name) \
- (UNPAREN type)name { \
return [_dictionary objectForKey: @#name]; \
}

虽然看上去很扯,但这的确能运行。预编译器将 type 扩展为 (id <NSCopying,NSCoding>),生成 UNPAREN (id<NSCopying, NSCoding>)。然后它会将 UNPAREN 宏扩展为 id <NSCopying,NSCoding>。括号,消失!

但是,之前使用的 GETTER 失败了。例如,GETTER(NSView *,view) 在宏扩展中生成 UNPAREN NSView *。不会进一步扩展就直接提供给编译器。结果自然会报编译器错误,因为 UNPAREN NSView * 是无法编译的。这虽然可以通过编写 GETTER((NSView *),view) 来解决,但是被迫添加这些括号很烦人。这样的结果可不是我们想要的。

宏不能被重载

我立刻想到了如何摆脱剩余的 UNPAREN。当你想要一个标识符消失时,你可以使用一个空的 #define,如下所示:

#define UNPAREN

有了这个,a UNPAREN b 的序列变为 a b。完美解决问题!但是,如果已经存在带参数的另一个定义,则预处理器会拒绝此操作。即使预处理器可能选择其中一个,它也不会同时存在两种形式。如果可行的话,这能有效解决我们的问题,但可惜的是并不允许:

#define UNPAREN(...) __VA_ARGS__
#define UNPAREN
#define GETTER(type,name) \
- (UNPAREN type)name { \
return [_dictionary objectForKey: @#name]; \
}

这无法通过预处理器,它会由于 UNPAREN 的重复 #define 而报错。不过,它引导我们走上了成功的道路。现在的瓶颈是怎么找出一种方法来实现相同的效果,而不会使两个宏具有相同的名称。

关键

最终目标是让 UNPAREN(x)UNPAREN((x)) 结果都是 x。朝着这个目标迈出的第一步是制作一些宏,其中传递 x(x) 产生相同的输出,即使它并不确定 x 是什么。这可以通过将宏名称放在宏扩展中来实现,如下所示:

#define EXTRACT(...) EXTRACT __VA_ARGS__

现在如果你写 EXTRACT(x),结果是 EXTRACT x。当然,如果你写 EXTRACT x,结果也是 EXTRACT x,就像没有宏扩展的情况。这仍然给我们留下一个 EXTRACT。虽然不能用 #define 直接解决,但这已经进步了。

标识符粘合

预处理器有一个操作符 ##,它将两个标识符粘合在一起。例如,a ## b 变为 ab。这可以用于从片段构造标识符,但也可以用于调用宏。例如:

#define AA 1
#define AB 2
#define A(x) A ## x

从这里可以看到,A(A) 产生 1A(B) 产生 2

让我们将这个运算符与上面的 EXTRACT 宏结合起来,尝试生成一个 UNPAREN 宏。由于 EXTRACT(...) 使用前缀 EXTRACT 生成实参,因此我们可以使用标识符粘合来生成以 EXTRACT 结尾的其他标记。如果我们 #define 那个新标记为空,那就搞定了。

这是一个以 EXTRACT 结尾的宏,它不会产生任何结果:

#define NOTHING_EXTRACT

这是对 UNPAREN 宏的尝试,它将所有内容放在一起:

#define UNPAREN(x) NOTHING_ ## EXTRACT x

不幸的是,这并不能实现我们的目标。问题在操作顺序上。如果我们写 UNPAREN((int)),我们将会得到:

UNPAREN((int))
NOTHING_ ## EXTRACT (int)
NOTHING_EXTRACT (int)
(int)

标示符粘合太早起作用,EXTRACT 宏永远不会有机会扩展开。

可以使用间接的方式强制预处理器用不同的顺序判断事件。我们可以制作一个 PASTE 宏,而不是直接使用 ##

#define PASTE(x,...) x ## __VA_ARGS__

然后我们将根据它编写 UNPAREN

#define UNPAREN(x)  PASTE(NOTHING_,EXTRACT x)

仍然不起作用。情况如下:

UNPAREN((int))
PASTE(NOTHING_,EXTRACT (int))
NOTHING_ ## EXTRACT (int)
NOTHING_EXTRACT (int)
(int)

但更接近我们的目标了。序列 EXTRACT(int) 显然没有触发标示符粘合操作符。我们必须让预处理器在它看到 ## 之前解析它。可以通过另一种方式间接强制解析它。让我们定义一个只包装 PASTEEVALUATING_PASTE 宏:

#define EVALUATING_PASTE(x,...) PASTE(x,__VA_ARGS__)

现在让我们用UNPAREN

#define UNPAREN(x) EVALUATING_PASTE(NOTHING_,EXTRACT x)

这是展开之后:

UNPAREN((int))
EVALUATING_PASTE(NOTHING_,EXTRACT (int))
PASTE(NOTHING_,EXTRACT int)
NOTHING_ ## EXTRACT int
NOTHING_EXTRACT int
int

即使没有额外加括号也能正常运行,因为额外的赋值并没有影响:

UNPAREN(int)
EVALUATING_PASTE(NOTHING_,EXTRACT int)
PASTE(NOTHING_,EXTRACT int)
NOTHING_ ## EXTRACT int
NOTHING_EXTRACT int
int

成功了!我们现在编写 GETTER 时可以不需要围绕类型的括号了:

#define GETTER(type,name) \
- (UNPAREN(type))name { \
return [_dictionary objectForKey: @#name]; \
}

奖励宏

在选择一些宏来证明这个结构时,我构建了一个很好的 dispatch_once 宏来制作延迟初始化的常量。实现如下:

#define ONCE(type,name,...) \
UNPAREN(type) name() { \
static UNPAREN(type) static_ ## name; \
static dispatch_once_t predicate; \
dispatch_once(&predicate,^{ \
static_ ## name = ({ __VA_ARGS__; }); \
}); \
return static_ ## name; \
}

使用案例:

ONCE(NSSet *,AllowedFileTypes,[NSSet setWithArray:@[ @"mp3",@"m4a",@"aiff" ]])

然后,你可以调用 AllowedFileTypes() 来获取集合,并根据需要高效创建集合。如果类型不巧包括括号,添加括号就能运行。

结论

仅仅写这个宏,我就发现了很多艰涩的知识。我希望接触这些知识也不会影响你的思维。请谨慎使用这些知识。

今天就这样。以后还会有更多令人兴奋的探索,可能比这还要再不可思议。在此之前,如果你对此主题有任何建议,请发送给 我们

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 中的集合(Set)

作者 SwiftGG
2019年7月9日 13:00

作者:Thomas Hanning,原文链接,原文日期:2018-09-06
译者:rsenjoyer;校对:numbbbbbpmst;定稿:Forelax

集合(Set)是 Swift 集合类型(collection types)之一,集合用来存储类型相同且没有确定顺序唯一的值。你可以将集合想象成一盒台球:它们在颜色和数量上独一无二,但在盒内是无序的。

提示:这篇文章使用的是 Swift 4 和 Xcode 10

创建集合

创建一个集合非常简单:

let setA: Set<String> = ["a","b","c"]

在这个例子中,创建一个 String 类型的集合,命名为 setA。它存储着 abc 三个值。与数组相比,集合内元素是无序的。通过编译器的类型推导功能,你也可以像如下方式创建集合:

let setB: Set = ["a","b","c"]

同样也可以使用集合的构造器:

let setC = Set(["a","b","c"])

跟数组一样,如果使用 let 来定义一个集合,它就是不可变的。使用 var定义的是一个可变集合。

var setD = Set(["a","b"])

稍后我们将了解更多有关可变集合的信息。

访问集合中的元素

你可以使用循环来访问集合中的元素:

for value in setA {
print(value)
}

注意:每次运行代码时,循环中值的顺序可能不同。从表面来看,它们像是随机返回一样。

集合分析

首先,你可以检查集合是否为空:

print(setA.isEmpty)

也可以获取集合中元素的个数:

print(setA.count)

上面的操作对数组同样有效,对集合而言,更加普遍的问题是判断集合中是否包含某个元素。为此,你可以使用 contains 方法:

print(setA.contains("a"))

从集合中添加和删除元素

你可以向可变集合里面添加和删除元素:

setD.insert("c")
setD.remove("a")

由于集合元素的唯一性,因此只能将同一个元素添加到集合中一次。可以多次使用相同的值调用 insert 方法,但集合不会改变。

var setE: Set = [1,2,3,4]

setE.insert(5)
setE.insert(5)
setE.insert(5)

print(setE) //[4,5,1,2,3]

和前面所说的一样,上面代码每次执行时输出的顺序可能不同,因为集合元素无序。

集合比较

集合间能进行比较。显然,可以比较两个集合是否相等:

let setA: = [“a”, “b”, “c”]
let setB: = [“a”, “b”, “c”]

if setA == setB {
print(“the sets are equal”)
}

这种情况下,集合是相等的。

比较两个集合的大小是没有明确的定义,但可以检查一个集合是否是另一个集合的子集:

let intSetA: Set = [1,2,3,4,5,6,7,8,9,0]
let intSetB: Set = [1,2,3]
intSetB.isSubset(of: intSetA) //true

也可以检查集合是否是另一个集合的真子集。这种情况就是该集合是另一个集合的子集但不想等。

let intSetA: Set = [1,2,3,4,5,6,7,8,9,0]
let intSetB: Set = [1,2,3,4,5,6,7,8,9,0]
let intSetC: Set = [3,4,5]

intSetB.isSubset(of: intSetA) //true
intSetB.isStrictSubset(of: intSetA) //false
intSetC.isSubset(of: intSetA) // true
intSetC.isStrictSubset(of: intSetA) //true

与之相对的概念就是超集:

let intSetA: Set = [1,2,3,4,5,6,7,8,9,0]
let intSetC: Set = [3,4,5]
intSetA.isSuperset(of: intSetC) //true
intSetA.isStrictSuperset(of: intSetC) //true

如果两个集合没有相同的元素,那么就说这两个集合不相交

let intSetA: Set = [1,2,3,4,5,6,7,8,9,0]
let intSetC: Set = [3,4,5]
let intSetD: Set = [13,14,15]

intSetA.isDisjoint(with: intSetC) //false
intSetA.isDisjoint(with: intSetD) //true

集合结合

你可以将两个集合合并成为一个新集合,新的集合中包含两个集合中所有的元素:

let stringSetA: Set = ["a","b","c"]
let stringSetB: Set = ["c","d","e"]

let unionSetAB = stringSetA.union(stringSetB)
print(unionSetAB) //["d", "b", "c", "a", "e"]

另一方面,交集就是仅包含两个集合中共同的元素:

let stringSetA: Set = ["a","b","c"]
let stringSetB: Set = ["c","d","e"]

let intersectionAB = stringSetA.intersection(stringSetB)
print(intersectionAB) //[“c”]

自定义集合元素类型

你可以在集合中存储自定义的类型。这种类型可以是类或者结构体。为了能正常使用集合,该类型必须遵循 hashable 协议。

class Movie: Hashable {

var title: String
var year: Int

init(title: String, year: Int) {
self.title = title
self.year = year
}

static func == (lhs: Movie, rhs: Movie) -> Bool {
return lhs.title == rhs.title &&
lhs.year == rhs.year
}

var hashValue: Int {
return title.hashValue ^ year.hashValue
}

}

let terminator = Movie(title: "Terminator", year: 1980)
let backToTheFuture = Movie(title: "Back to the Future", year: 1985)

let movieSetA: Set = [terminator,backToTheFuture]

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

PhotoKit 的数据模型

作者 SwiftGG
2019年7月1日 13:00

作者:Ole Begemann,原文链接,原文日期:2018-09-28
译者:张弛;校对:numbbbbbYousanflics;定稿:Forelax

在 iOS 系统中,PhotoKit 框架 不仅被系统的照片 App 所使用,同时它也为开发人员访问设备的照片库提供了接口支持。而它的底层则是 Core Data 实现的。

至少从这两个地方,你就可以确认这一点:

  1. 编写一个能够访问照片库的应用,并使用 -com.apple.CoreData.SQLDebug 1. 来启动这个应用程序。当你访问照片库时,从控制台就可以看到 Core Data 的调试信息。
  2. 如果查看 PhotoKit 框架的 class dump,你将会在主要的类中看到对 NSManagedObjectID 和其他 Core Data 类型的引用,例如, PHObject 有一个 _objectID:NSManagedObjectID 的 ivar

寻找 PhotoKit 的核心数据模型

为了更好地理解 PhotoKit 框架(特别是它的性能特征),我检查了它的数据模型。我在 Xcode 10.0 应用程序的包内容中找到了一个名为 PhotoLibraryServices.framework / photos.momd / photos-10.0.mom 的文件:

/Applications/Xcode-10.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/PhotoLibraryServices.framework/photos.momd/photos-10.0.mom

你可以使用如下的命令来查找 Xcode 中模拟器运行时内的其他 Core Data 模型:

find /Applications/Xcode-10.app -name '*.mom'

在 Xcode 中打开已编译的 Core Data 模型

.mom 文件是已编译的 Core Data 数据模型。Xcode 无法直接打开它,但可以将它导入到另一个 Core Data 模型中。通过如下的步骤,我们就可以在 Xcode 中查看这个模型:

  1. 创建一个新的空项目。因为使用 Xcode 10 在项目之外显示 Core Data 模型包并不是一个好的选择。
  2. 在项目中创建一个全新的“Core Data 数据模型”文件。 这将创建一个 .xcdatamodeld 包。
  3. 打开新数据模型,然后选择 编辑器 > 导入…. ,选择要导入的 .mom 文件。

不幸的是,编译的模型并不存储 Xcode 的模型编辑器的布局信息,因此你必须手动将编辑器中的实体拖出来一个漂亮的样式中。这花了我几个小时。

温馨提示:你可以使用箭头键(和 shift 键+箭头键)精确定位事物。

专家提示:请勿点击 ⌘Z 撤消移动操作。对图形的编辑不会被 Xcode 视作一个可撤销的操作,因此 Xcode 可能会撤消一开始的导入操作,这意味着你将丢失所有未保存的工作。

带有良好格式的 PhotoKit 的模型

这是与 Xcode 10.0(iOS 12.0)捆绑在一起的 photos-10.0.mom

iOS 12.0 中 PhotoLibraryServices.framework 的 Core Data 数据模型。请下载图片并在本地打开以获得最佳效果。

并非所有的内容都能在这张图片中被看到。你可以 下载完整模型 并在 Xcode 中查看它的一些属性。

请注意,这不一定是 iOS 上的照片的完整数据模型。更多的一些 Core Data 模型被放置在 PrivateFrameworks/PhotoAnalysis.framework 中。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

使用 Swift 实现基于堆的优先级队列

作者 SwiftGG
2019年5月6日 13:00

作者:APPCODA EDITORIAL TEAM,原文链接,原文日期:2019-01-07
译者:Roc Zhang;校对:pmstnumbbbbb;定稿:Forelax

在计算机科学中,有很多问题可以通过将底层数据结构用优先级队列实现来改善算法的时间复杂度。其中 Dijkstra 的最短路径算法便是一个例子,该算法使用了优先级队列来在图中搜索两个顶点间的最短路径。

不幸的是,Swift 的标准库中并没有提供优先级队列的默认实现。所以我们将会研究如何自行实现基于堆的优先级队列。

如果你想在自己的 IDE 中一起动手来操作,点击 此链接 便可获取到源码。

什么是优先级队列?

优先级队列是一种可以对具有相对优先级的对象进行高效排序的数据结构。它会根据队列中每个对象的优先级,一个个将队列中的对象进行排序。

假设你创建了一系列任务并准备在将来的某个时间点运行它们,利用优先级队列就可以让这些任务按照你预期执行。

在接下来的文章中,我们将使用堆结构来实现我们的优先级队列。

什么是堆?

我们可以把堆看作是每个节点最多只有两个子节点的树,但与树不同的是,向堆中添加新节点时要尽可能往顶层左侧放置。如下图所示:

同时,堆还具有着一些与节点间相对大小关系相关的特性。一个最小堆(就是我们即将要使用的)有着每一个节点比其子节点都要小的特性。最大堆则正好相反。

为了能够维持这种性质,我们需要通过一些操作来得到节点的正确位置顺序。当我们插入一个新节点时,先将它放在树的顶层左侧开始的第一个空余可用的位置上。如果在放置后最小堆的性质不成立,则将此节点与它的父节点交换,直到最小堆性质成立为止。下图展示了向一个已有的最小堆中插入数字 2 的情况。

当要把一个对象移出队列时,需限制只从队列的某一端进行操作。在这里我们将通过限定只能删除根节点的方式来实现。当根节点被移除时,会被顶层最右边的节点替代。由于新节点成为根节点后有很大概率会过大,我们将把它向下移动,把它与最小的子节点交换,直到我们恢复最小堆。

关于实现本身的简短说明

我们将采用数组来实现一个既快速又节省空间的树结构。这里我不打算过于深入其中的数学运算,但如果你有兴趣的话,可以看一看这个 链接,它解释了数学在其中运用的方式与背后的原因。

准备好了吗?我们开始吧。

设计协议

同往常一样,我们先来定义对象要展示给外部用户怎样的功能。我们以定义协议的方式来完成这件事,稍后再让具体的类来遵循它。我为队列设计的协议如下:

protocol Queue {
associatedtype DataType: Comparable

/**
将一个新元素插入到队列中。
- 参数 item:要添加的元素。
- 返回值:插入是否成功。
*/
@discardableResult func add(_ item: DataType) -> Bool

/**
删除首个元素。
- 返回值:被移除的元素。
- 抛出值:QueueError 类型的错误。
*/
@discardableResult func remove() throws -> DataType

/**
获取到队列中的首个元素,并将其移出队列。
- 返回值:一个包含队列中首个元素的可选值。
*/
func dequeue() -> DataType?

/**
获取队列中的首个元素,但不将它移出队列。
- 返回值:一个包含队列中首个元素的可选值。
*/
func peek() -> DataType?

/**
清空队列。
*/
func clear() -> Void
}

该协议明确了我们需要实现的功能,供外部用户调用。协议同样还说明了其中的某一个方法可能会抛出错误,且根据文档我们能够了解到它会是一个 QueueError 类型的错误,因此我们同样也要实现它。

enum QueueError: Error {
case noSuchItem(String)
}

这段代码非常简明扼要:当用户尝试从空队列中删除元素时,我们会抛出上面这样的错误。

现在所有的准备工作已经完成,让我们开始实现队列本身。

实现优先级队列

我们将首先从声明 PriorityQueue 类开始,然后再实现它的初始化方法与存储元素,同时完成一些“有则更好”的方法。代码看起来是这样的:

/**
基于堆数据结构的 PriorityQueue 实现。
*/

class PriorityQueue<DataType: Comparable> {

/**
队列的存储。
*/
private var queue: Array<DataType>

/**
当前队列的大小。
*/
public var size: Int {
return self.queue.count
}

public init() {
self.queue = Array<DataType>()
}
}

你也许注意到了,我们目前还没有实现队列的协议。当我们进行编码时,通常希望事物之间能保持相对分离。并且希望能创建出一个概览从而方便我们去进行查找。有些类可能会逐渐变得非常大,解决这种情况的方法之一是使用扩展作用域。这样,每一个扩展倾向于只做一个任务(比如去遵循一个协议,处理存储与初始化,又或是嵌套类的声明等),事后再去查找时就会容易很多。让我们在这里也尝试使用这种方式。首先,实现一个 Int 类型的私有扩展,这能够帮助我们执行一些预先定义好的索引计算:

private extension Int {
var leftChild: Int {
return (2 * self) + 1
}

var rightChild: Int {
return (2 * self) + 2
}

var parent: Int {
return (self - 1) / 2
}
}

由于是私有的访问权限,这个扩展只在 PriorityQueue 文件中可用。这里聚集了我们将要使用的获取某个节点的子节点与父节点的计算。这样我们就可以通过调用 .leftChild 属性来方便的获取到左子节点的索引,而不必在实现中去进行一堆的数学运算了,以此类推。

下面是我们对 Queue 协议的遵循实现:

extension PriorityQueue: Queue {
@discardableResult
public func add(_ item: DataType) -> Bool {
self.queue.append(item)
self.heapifyUp(from: self.queue.count - 1)
return true
}

@discardableResult
public func remove() throws -> DataType {
guard self.queue.count > 0 else {
throw QueueError.noSuchItem("Attempt to remove item from an empty queue.")
}
return self.popAndHeapifyDown()
}

public func dequeue() -> DataType? {
guard self.queue.count > 0 else {
return nil
}
return self.popAndHeapifyDown()
}

public func peek() -> DataType? {
return self.queue.first
}

public func clear() {
self.queue.removeAll()
}

/**
弹出队列中的第一个元素,并通过将根元素移向队尾的方式恢复最小堆排序。
- 返回值: 队列中的第一个元素。
*/
private func popAndHeapifyDown() -> DataType {
let firstItem = self.queue[0]

if self.queue.count == 1 {
self.queue.remove(at: 0)
return firstItem
}

self.queue[0] = self.queue.remove(at: self.queue.count - 1)

self.heapifyDown()

return firstItem
}

/**
通过将元素移向队头的方式恢复最小堆排序。
- 参数 index: 要移动的元素的索引值。
*/
private func heapifyUp(from index: Int) {
var child = index
var parent = child.parent

while parent >= 0 && self.queue[parent] > self.queue[child] {
swap(parent, with: child)
child = parent
parent = child.parent
}
}

/**
通过将根元素移向队尾的方式恢复队列的最小堆排序。
*/
private func heapifyDown() {
var parent = 0

while true {
let leftChild = parent.leftChild
if leftChild >= self.queue.count {
break
}

let rightChild = parent.rightChild
var minChild = leftChild
if rightChild < self.queue.count && self.queue[minChild] > self.queue[rightChild] {
minChild = rightChild
}

if self.queue[parent] > self.queue[minChild] {
self.swap(parent, with: minChild)
parent = minChild
} else {
break
}
}
}

/**
交换存储中位于两处索引值位置的元素。
- 参数 firstIndex:第一个要交换元素的索引。
- 参数 secondIndex:第二个要交换元素的索引。
*/
private func swap(_ firstIndex: Int, with secondIndex: Int) {
let firstItem = self.queue[firstIndex]
self.queue[firstIndex] = self.queue[secondIndex]
self.queue[secondIndex] = firstItem
}
}

这里的内容有点多,你也许会想多读上一两次。其中,最上面是我们先前在协议中所定义好的所有方法,下面则是一些私有的,仅在此类中可用的辅助方法。我已经为这些辅助方法加上了注释,以便你能快速的了解到它们是用来做什么的。此外,记得关注一下先前对 Int 的扩展在这里是如何被使用的。依我看来,这是非常简洁实用的设计。

总结

现在,我们已经完成了所有 PriorityQueue 所需要的功能。现在我们将添加对 CustomStringConvertible 协议的实现,以便在向 print 函数传入一个队列后能得到一些可阅读的内容:

extension PriorityQueue: CustomStringConvertible {
public var description: String {
return self.queue.description
}
}

赞!

上述就是这次的全部内容了。现在你已经知道了如何去实现一个基于堆数据结构的优先级队列。如果有任何疑问,欢迎发表评论。

要了解 iOS 开发的更多信息,请查看我之前的文章:
Introduction To Protocol Oriented Programming
Using Swift Extensions To Clean Up Our Code

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

以流的形式执行 Multipart 请求

作者 SwiftGG
2019年1月21日 14:00

作者:Soroush Khanlou,原文链接,原文日期:2018-11-14
译者:郑一一;校对:numbbbbbpmst;定稿:Forelax

Foundation 框架中的 URL 类提供了非常全面的功能,此后还在 iOS 7 中新增了 URLSession 类。尽管如此,基础库中仍然缺少 multipart 文件上传的功能。

什么是 multipart 请求?

Multipart 编码实际上就是在网络中上传大型文件的方法。在浏览器中,有时候你会选择一个文件作为表单提交内容的一部分。这个文件便是以 multipart 请求的方式实现上传的。

乍一看,multipart 请求和一般请求差不多。不同之处是 multipart 请求额外为 HTTP 请求体指定了唯一编码。同 JSON 编码({"key": "value"})或者 URL 字符编码 (key=value) 相比,multipart 编码干的事略微有所不同。因为 multipart 请求体实际上只是一串字节流,接收端实体在解析数据时,需要知道字节流中各个部分之间的界限。所以 multipart 请求需要使用 “boundaries” 来解决这个问题。在请求首部的 Content-Type 中,可以定义 boundary:

Accept: application/json
Content-Type: multipart/form-data; boundary=khanlou.comNczcJGcxe

Boundary 的具体内容并不重要,唯一需要注意的是:在请求体中,boundary 是不能重复出现(这样才能体现 boundary 的作用)。你可以使用 UUID 作为 boundary。

请求的每一部分可以是普通数据(比如图片)或者元数据(一般是文本,对应一个名字,组成一个键值对)。如果数据是图片的话,那它看起来应该是这样的:

--<boundary>
Content-Disposition: form-data; name=<name>; filename=<filename.jpg>
Content-Type: image/jpeg

<image data>

如果是普通文本,则是这样:

--<boundary>
Content-Disposition: form-data; name=<name>
Content-Type: text/plain

<some text>

请求结尾会有一个带着两个连字符的 boundary,--<boundary>--。(此处需要注意,所有新行必须是回车换行。)

以上就是关于 multipart 请求的所有内容,并不是特别复杂。事实上,当在写第一个有关 multipart 编码的客户端实现时,我有些抵触阅读 multipart/form-data 的 RFC。可是在开始阅读之后,我对这个协议的理解更深了。整个文档可读性很强,很轻易就能直达知识的源头。

我在开源的 Backchannel SDK 实现了上述功能。BAKUploadAttachmentRequestBAKMultipartRequestBuilder 类包含了处理 mulitipart 的方法。在这个项目中,仅仅包含了处理单个文件的情况,并且没有包括元数据。但是作为范例,依旧很好地展示了 mulitipart 请求是如何构建的。可以通过添加额外的实现代码,来支持元数据和多文件的功能。

无论是使用一个请求上传多个文件,还是多个请求分别对应上传一个文件,来实现多文件上传功能,都会碰到一个问题。这个问题就是,如果你尝试一次性上传很多文件的话,app 将会闪退。这是因为使用 该版本的代码,加载的数据会直接进入内存,在内存暴涨的情况下,即使使用当下性能最强的旗舰手机也会有闪退发生。

将硬盘中数据以流的形式读取

最常见的解决方法是将硬盘中的数据以流的形式读取出来。其核心思想是文件的字节数据会一直保存在硬盘里,直到被读取并发往网络。内存中只保留了很小一部分的镜像数据。

目前,我想出两种方法可以解决这个问题。第一个方法,把 multipart 请求体中的所有数据写到硬盘的一个新文件中,并使用 URLSession 的 uploadTask(with request: URLRequest, fromFile fileURL: URL) 方法将文件转化为流。这个方法可以奏效,但我并不想为每一个请求新建一个新文件保存到硬盘中。因为这意味着在请求发出后,还需要删除这个文件。

第二种方法是将内存和硬盘的数据合并在一起,并通过统一的接口向网络输出数据。

如果你觉得第二种方法听起来像是 类簇,恭喜你,完全正确。很多常用 Cocoa 类都允许创建子类,并实现一些父类方法,使其和父类表现一致。回想一下 NSArray-count 属性和 -objectAtIndex: 方法。因为 NSArray 的所有其它方法都是基于 -count 属性和 -objectAtIndex: 方法实现的,你可以非常轻易地创建优化版本的 NSArray 子类。

你可以创建一个 NSData 子类,它无需真正从硬盘读取数据,而只是创建一个指针直接指向硬盘中的数据。这样做的好处是是不需要把数据载入内存中进行读取。这种方法称为内存映射,基于 Unix 方法 mmap。你可以通过 .mappedIfSafe 或者 alwaysMapped 选项,来使用 NSData 的这项特性。因为 NSData 是一个类簇,我们将创建一个 ConcatenatedData 子类(就像 FlattenCollection 在 Swift 中的工作方式),该子类会将多个 NSData 对象视作一个连续的 NSData。完成创建以后,我们就做好所有准备来解决这个问题啦。

通过查看 NSData 所有原生方法,可以发现,需要实现的是 -count-bytes。实现 -count 并不难,我们可以把所有 NSData 对象的大小相加得到;但在实现 -bytes 时则会有个问题。 -bytes 需要返回一个指向一段连续缓冲区的指针,而目前我们并没有这个指针。

在基础库中,提供了 NSInputStream 类用于处理不连续的数据。非常幸运,NSInputStream 同样是一个类簇。我们可以创建一个子类,将多条流合并。在使用子类时,感觉上就像是一条流。通过使用 +inputStreamWithData:+inputStreamWithURL: 方法,可以轻易地创建一条输入流,用来代表硬盘中的文件和内存中的数据(比如 boundaries)。

通过阅读最好的第三方网络库源代码,你会发现 AFNetworking 采用了这种方法。(Alamofire,Swift 版本的 AFNetworking,则采用了第一种方法,将数据全部加载到内存中,但如果数据量太大,就会写到硬盘的一个文件中。)

将所有部分拼接起来

你可以在 这里 看看我的串行输入流的实现(是用 Objective-C 实现的,以后我可能还会写一个 Swift 版本的)。

通过 SKSerialInputStream 类,可以将流组合在一起。下面展示了前缀和后缀属性:

extension MultipartComponent {
var prefixData: Data {
let string = """
\(self.boundary)
Content-Disposition: form-data; name="\(self.name); filename="\(self.filename)"
"""
return string.data(using: .utf8)
}

var postfixData: Data {
return "\r\n".data(using: .utf8)
}
}

将元数据和文件的 dataStream 组合在一起,得到一条输入流:

extension MultipartComponent {
var inputStream: NSInputStream {

let streams = [
NSInputStream(data: prefixData),
self.fileDataStream,
NSInputStream(data: postfixData),
]

return SKSerialInputStream(inputStreams: streams)
}
}

创建好每一部分输入流之后,就可以把所有流组合在一起,得到一条完整输入流。此外,在请求结尾还需要添加一个 boundary:

extension RequestBuilder {
var bodyInputStream: NSInputStream {
let stream = parts
.map({ $0.inputStream })
+ [NSInputStream(data: "--\(self.boundary)--".data(using: .utf8))]

return SKSerialInputStream(inputStreams: streams)
}
}

最后,将 bodyInputStream 赋值给 URL 请求的 httpBodyStream 属性:

let urlRequest = URLRequest(url: url)

urlRequest.httpBodyStream = requestBuilder.bodyInputStream;

注意,httpBodyStreamhttpBody 两个属性是互斥的——两个属性不会同时生效。设置 httpBodyStream 会使得 Data 版本 httpBody 失效,反之亦然。

流文件上传的关键是能够将多条输入流合并成一条流。SKSerialInputStream 类完成了整个工作。尽管说子类化 NSInputStream 有一些困难,可一旦解决这个问题,我们就离成功不远啦。

子类化过程中需要注意的问题

子类化 NSInputStream 的过程不会太轻松,甚至可以说很困难。你必须实现 9 个方法。其中的 7 个方法,父类只有一些微不足道的默认实现。而在文档中只提到了 9 个方法中的 3 个,所以你还得实现 6 个 NSStreamNSInputStream 的父类)的方法,其中有 2 个是 run loop 方法,并允许空实现。在这之前,你还需要额外 实现 3 个私有方法,不过现在不必实现了。此外,还需要定义 3 个只读属性:streamStatusstreamErrordelegate

在处理完上述子类化相关的细节后,接下来的挑战是创建一个 NSInputStream 子类,其行为应该和 API 使用者所期望的保持一致。然而,这个类状态的重度耦合是不容易被人发现的。

有一些状态需要保证行为一致。举个例子,hasBytesAvailable 是不同于其它状态的,但还是存在细微的联系。在我最近发现的一个 bug 里,hasBytesAvailable 属性会返回 self.currentIndex != self.inputStreams.count,但是这会造成一个 bug,流会一直处于开启的状态,并最终造成请求超时。修复这个 bug 的办法是改为返回 YES,但我一直没有找到这个 bug 的根源所在。

另外一个状态 streamStatus,存在许多可能的值,其中比较重要的两个值是 NSStreamStatusOpenNSStreamStatusClosed

最后一个比较有意思的状态是字节数,从 read 方法中返回值。这个属性除了会返回正整型数之外,还会返回 -1,-1 代表有错误产生,需要进一步检查非空属性 streamError 来获取更多信息。字节数还可以返回 0,根据文档描述,这是标明流结尾的另外一种方式。

文档并不会告诉你哪些状态的组合是有意义的。比如说流产生一个 streamError,但状态却是 NSStreamStatusClosed,而不是 NSStreamStatusError,在这种情况下是否会有问题?想要管理好所有的状态非常难,不过到最后终究还是能解决的。

对于 SKSerialStream 类,是否可以在所有情况下都能正常工作,我还不是特别有信心。但看起来,SKSerialStream 通过使用 URLSession 能很好地支持上传 multipart 数据。如果你在使用这份代码的时候发现任何问题,请务必联系我,我们可以一起不断优化这个类。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Hacking Hit Tests

作者 SwiftGG
2018年12月27日 14:00

作者:Soroush Khanlou,原文链接,原文日期:2018-09-07
译者:Nemocdz;校对:Yousanflicspmst;定稿:Forelax

回想 Crusty 教我们使用面向协议编程之前的日子,我们大多使用继承来共享代码的实现。通常在 UIKit 编程中,你可能会用 UIView 的子类去添加一些子视图,重写 -layoutSubviews,然后重复这些工作。也许你还会重写 -drawRect。但当你需要做一些特别的事情时,就需要看看 UIView 中其他可以被重写的方法。

UIKit 有个十分古怪的地方,那是它的触摸事件处理系统。它主要包括两个方法,-pointInstide:withEvent:-hitTest:withEvent:

-pointInside: 会告诉调用者给定点是否包含在指定的视图区域中。而 -hitTest:pointInside: 这个方法来告诉调用者哪个子视图(如果有的话)是当前触摸在给定点的接收者。现在我比较感兴趣的是后面这个方法。

苹果的文档勉强能够让你理解怎么重新实现这个方法。在你学会怎么重新实现方法之前,你都不能改变它的功能。接下来让我们看一遍 文档,并尝试重写这个函数。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// ...
}

首先,让我们从文档的第二段开始吧:

这个方法会忽略那些隐藏的视图,禁用用户交互视图和 alpha 等级小于 0.01 的视图。

让我们通过一些 gurad 语句来快速预处理这些前提条件。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

guard isUserInteractionEnabled else { return nil }

guard !isHidden else { return nil }

guard alpha >= 0.01 else { return nil }

// ...

相当简单吧。那接下来是?

这个方法调用 pointInside:withEvent: 方法来遍历接收视图层级中每一个子视图,来决定哪个子视图来接收该触摸事件。

逐字阅读文档后,感觉 -pointInside: 会在每一个子视图里被调用(用一个 for 循环),但这并不是完全正确的。

感谢这个 读者。通过他在 -hitTest:-pointInside: 中放置了断点的试验,我们知道 -pointInside: 会在 self 中调用(在有上面那些 guard 的情况下),而不是在每一个子视图中。 所以应该添加另外的 guard 语句,像下面这行代码一样:

guard self.point(inside: point, with: event) else { return nil }

-pointInside:UIView 另一个需要重写的方法。它的默认实现会检查传入的某个点是否包含在视图的 bounds 中。如果调用 -pointInside 返回 true,那么意味着触摸事件发生在它的 bounds 中。

理解完这个小小的差别后,我们可以继续阅读文档了:

如果 -pointInside:withEvnet: 返回 YES,那么子视图的层级也会进行类似的遍历直到找到包含指定点的最前面的视图。

所以,从这里知道我们需要遍历视图树。这意味着循环遍历所有的视图,并调用 -hitTest: 在它们每一个上去找到合适的子视图。在这种情况下,这个方法是递归的。

为了遍历视图层级,我们需要一个循环。然而,这个方法其中一个更反人类的是需要反向遍历视图。子视图数组中尾部的视图反而会处在 Z 轴中更高的位置,所以它们应该被最先检验。(如果没有这篇 文章,我可记不起这个点。)

// ...
for subview in subviews.reversed() {

}
// ...

传入的坐标点会转换到当前视图的坐标系中,而非我们关心子视图中。幸运的是,UIKit 给了一个处理函数,去转换坐标点的参考系到其他任何的视图的 frame 的参考系中。

// ...
for subview in subviews.reversed() {
let convertedPoint = subview.convert(point, from: self)
// ...
}
// ...

一旦有了转换后的坐标点,我们就可以很简单地询问每一个子视图该点的目标视图。需要注意的是,如果点处于该视图外部(也就是说,-pointInside: 返回 false),-hitTest 会返回 nil。这时就应该检查层级里的下一个子视图。

// ...
let convertedPoint = subview.convert(point, from: self)
if let candidate = subview.hitTest(convertedPoint, with: event) {
return candidate
}
//...

一旦我们有了合适的循环语句,最后一件需要做的事是 return self。如果视图是可被点击(被我们的 guard 语句断言过的情况),但却没有子视图想要处理这个触摸的话,意味着当前视图,也就是 self,是这个触摸正确的目标。

这是完整的算法:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

guard isUserInteractionEnabled else { return nil }

guard !isHidden else { return nil }

guard alpha >= 0.01 else { return nil }

guard self.point(inside: point, with: event) else { return nil }

for subview in subviews.reversed() {
let convertedPoint = subview.convert(point, from: self)
if let candidate = subview.hitTest(convertedPoint, with: event) {
return candidate
}
}
return self
}

现在我们有了一个参考的实现,可以开始修改它来实现具体的行为。

在之前的这篇播客《Changing the size of a paging scroll view》中,我就已经讨论过其中一种行为。我谈到一种“落后并该被废弃”的方法来产生这种效果。本质上,你必须:

  1. 关掉 clipsToBounds
  2. 在滑动区域中放一个非隐藏视图
  3. 在非隐藏视图上重写 -hitTest: 来传递所有触摸到 scrollview 中

-hitTest: 方法是这种技术的基石。因为在 UIKit 中,hitTest 方法会代理给每一个视图去实现,决定触摸事件传递给哪个视图接收。这可以让你去重写默认的实现(期望和普通的实现)并替换它为你想做的,甚至返回一个不是原始视图的子视图。多么疯狂。

让我们看一下另一个例子。如果你已经用过 Beacon 今年的版本,你会注意到滑动删除事件行为的物理效果感觉上和其他用原生系统实现的效果有点不一样。这是因为用系统的途径不能完全获得我们想要的表现,所以需要自己重新实现这个功能。

如你所想,重写滑动和反弹物理效果不需要那么复杂,所以我们用一个 UIScrollView 和将 pagingEnabled 设为 true 来获得尽可能自由的反弹力。用和这篇旧博客里说的类似的技术,将滑动的视图的 bounds 设置得更小一些并将 panGestureRecognizer 移到事件的 cell 顶层的一个覆盖视图中,来设置一个自定义页面大小。

然而,当覆盖视图正确的传递触摸事件到 scroll view 时,那里会有覆盖视图不能正确拦截的其他事件。cell 包含着按钮,像 “join event” 按钮和 “delete event” 按钮,都需要接收触摸。有几种自定义实现在 -hitTest: 中可以处理这种情况,其中一种实现就是直接检查这两个按钮的子视图:

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

guard isUserInteractionEnabled else { return nil }

guard !isHidden else { return nil }

guard alpha >= 0.01 else { return nil }

guard self.point(inside: point, with: event) else { return nil }

if joinButton.point(inside: convert(point, to: joinButton), with: event) {
return joinButton
}

if isDeleteButtonOpen && deleteButton.point(inside: convert(point, to: deleteButton), with: event) {
return deleteButton
}
return super.hitTest(point, with: event)
}

这种方法会正确地传递正确的点击事件到正确的的按钮中,而且不用打断显示删除按钮的滑动表现。(你可以尝试只忽略 deletionOverlay,不过它不会正确的传递滑动事件。)

-hitTest: 是视图中一个很少重写的地方,但是在需要时,可以提供其他工具很难做到的行为。理解如何自己实现有助于随意替换它。你可以用这个技术去扩大点击的目标区域,去除触摸处理中的某些子视图,而不用把它们从可见的层级中去掉,又或是用一个视图作为另一个将响应触摸的视图的兜底。所有东西都是可能的。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

❌
❌