作者:Terhechte,原文鏈接腻菇,原文日期:2016/07/15
譯者:BigbigChai作岖;校對:way;定稿:千葉知風
Swift 3 : 從 NSData 到 Data 的轉變
Swift 3 帶來了許多大大小小的變化胁住。其中一個是為常見的 Foundation 引用類型(例如將 NSData 封裝成 Data
趁猴,將 NSDate 封裝成 Date
)添加值類型的封裝。這些新類型除了改變了內存行為和名字以外彪见,在方法上也與對應的引用類型有所區(qū)別 <a id="fnr.1" name="fnr.1" class="footref" href="#fn.1">1</a>儡司。 從更換新方法名這類小改動,到完全去掉某一功能這種大改動余指,我們需要一些時間去適應這些新的值類型捕犬。本文會重點介紹作為值類型的 Data
是如何封裝 NSData
的。
不僅如此酵镜,在學習完基礎知識之后碉碉,我們還會寫一個簡單的示例應用。這個應用會讀取和解析一個 Doom 毀滅戰(zhàn)士的 WAD 文件 <a id="fnr.2" name="fnr.2" class="footref" href="#fn.2">2</a>淮韭。
基本區(qū)別
對于 NSData
垢粮,其中一個最常見的使用場景就是調用以下方法加載和寫入數據:
func writeToURL(_ url: NSURL, atomically atomically: Bool) -> Bool
func writeToURL(_ url: NSURL, options writeOptionsMask: NSDataWritingOptions) throws
// ... (implementations for file: String instead of NSURL)
init?(contentsOfURL url: NSURL)
init(contentsOfURL url: NSURL, options readOptionsMask: NSDataReadingOptions) throws
// ... (implementations for file: String instead of NSURL)
基本的使用方法并沒有什么改動。新的 Data
類型提供了以下方法:
init(contentsOf: URL, options: ReadingOptions)
func write(to: URL, options: WritingOptions)
留意到 Data
簡化了從文件讀寫數據的方法靠粪,原本 NSData
提供了多種不同的方法蜡吧,現在只精簡到兩個方法。
比較一下 NSData
和 Data
的方法占键,可以發(fā)現另一個變化昔善。NSData
提供了三十個方法和屬性,而 Data
提供了一百三十個畔乙。Swift 強大的協議擴展可以輕易地解釋這個巨大的差異君仆。Data
從以下協議里獲得了許多方法:
- CustomStringConvertible
- Equatable
- Hashable
- MutableCollection
- RandomAccessCollection
- RangeReplaceableCollection
- ReferenceConvertible
這給 Data
提供了許多 NSData
不具備的功能。這里列出部分例子:
func distance(from: Int, to: Int)
func dropFirst(Int)
func dropLast(Int)
func filter((UInt8) -> Bool)
func flatMap<ElementOfResult>((UInt8) -> ElementOfResult?)
func forEach((UInt8) -> Void)
func index(Int, offsetBy: Int, limitedBy: Int)
func map<T>((UInt8) -> T)
func max()
func min()
func partition()
func prefix(Int)
func reversed()
func sort()
func sorted()
func split(separator: UInt8, maxSplits: Int, omittingEmptySubsequences: Bool)
func reduce<Result>(Result, (partialResult: Result, UInt8) -> Result)
如你所見牲距,許多函數式方法袖订,例如 mapping 和 filtering 現在都可以操作 Data
類型的字節(jié)內容了。我認為這是相對 NSData
的一大進步嗅虏。優(yōu)勢在于洛姑,現在可以輕松地使用下標以及對數據內容進行比較了。
var data = Data(bytes: [0x00, 0x01, 0x02, 0x03])
print(data[2]) // 2
data[2] = 0x09
print (data == Data(bytes: [0x00, 0x01, 0x09, 0x03])) // true
Data
還提供了一些新的初始化方法專門用于處理 Swift 里常見的數據類型:
init(bytes: Array<UInt8>)
init<SourceType>(buffer: UnsafeMutableBufferPointer<SourceType>)
init(repeating: UInt8, count: Int)
獲取字節(jié)
如果你使用 Data
與底層代碼(例如 C
庫)交互皮服,你會發(fā)現另一個明顯的區(qū)別:Data
缺少了 NSData
的 getBytes
方法:
// NSData
func getBytes(_ buffer: UnsafeMutablePointer<Void>, length length: Int)
getBytes
方法有許多不同的應用場景楞艾。其中最常見的是参咙,當你需要解析一個文件并按字節(jié)讀取并存儲到數據類型/變量里。例如說硫眯,你想讀取一個包含項目列表的二進制文件蕴侧。這個文件經過編碼,而編碼方式如下:
數據類型 | 大小 | 功能 |
---|---|---|
Char | 4 | 頭部 (ABCD) |
UInt32 | 4 | 數據開始 |
UInt32 | 4 | 數量 |
該文件包含了一個四字節(jié)字符串 ABCD 標簽两入,用來表示正確的文件類型(做校驗)净宵。接著的四字節(jié)定義了實際數據(例如頭部的結束和項目的開始),頭部最后的四字節(jié)定義了該文件存儲項目的數量裹纳。
用 NSData
解析這段數據非常簡單:
let data = ...
var length: UInt32 = 0
var start: UInt32 = 0
data.getBytes(&start, range: NSRange(location: 4, length: 4))
data.getBytes(&length, range: NSRange(location: 8, length: 4))
如此將返回正確結果<a id="fnr.3" name="fnr.3" class="footref" href="#fn.3">3</a>择葡。如果數據不包含 C 字符串,方法會更簡單剃氧。你可以直接用正確的字段定義一個 結構體
敏储,然后把字節(jié)讀到結構體里:
數據類型 | 大小 | 功能 |
---|---|---|
UInt32 | 4 | 數據開始 |
UInt32 | 4 | 數量 |
let data = ...
struct Header {
let start: UInt32
let length: UInt32
}
var header = Header(start: 0, length: 0)
data.getBytes(&header, range: NSRange(location: 0, length: 8))
Data 中 getBytes 的替代方案
不過 Data
里 getBytes 這個功能不再可用,轉而提供了一個新方法作替代:
// 從數據里獲得字節(jié)
func withUnsafeBytes<ResultType, ContentType>((UnsafePointer<ContentType>) -> ResultType)
通過這個方法朋鞍,我們可以從閉包中直接讀取數據的字節(jié)內容已添。來看一個簡單的例子:
let data = Data(bytes: [0x01, 0x02, 0x03])
data.withUnsafeBytes { (pointer: UnsafePointer<UInt8>) -> Void in
print(pointer)
print(pointer.pointee)
}
// 打印
// : 0x00007f8dcb77cc50
// : 1
好了,現在有一個指向數據的 unsafe UInt8 指針滥酥,那要怎樣利用起來呢更舞?首先,我們需要一個不同的數據類型坎吻,然后一定要確定該數據的類型缆蝉。我們知道這段數據包含一個 Int32 類型,那該如何正確地解碼呢禾怠?
既然已經有了一個 unsafe pointer(UInt8 類型),那么就能夠輕松地轉換成目標類型 unsafe pointer贝搁。UnsafePointer
有一個 pointee
屬性吗氏,可以返回指針所指向數據的正確類型:
let data = Data(bytes: [0x00, 0x01, 0x00, 0x00])
let result = data.withUnsafeBytes { (pointer: UnsafePointer<Int32>) -> Int32 in
return pointer.pointee
}
print(result)
//: 256
如你所見,我們創(chuàng)建了一個字節(jié)的 Data
實例雷逆,通過在閉包里定義 UnsafePointer<Int32>
弦讽,返回 Int32
類型的數據“蛘埽可以把代碼寫得再精簡一點往产,因為編譯器能夠根據上下文推斷結果類型:
let result: Int32 = data.withUnsafeBytes { $0.pointee }
數據的生命周期
使用 withUnsafeBytes
時,指針(你所訪問的)的生命周期是一個很重要的考慮因素(除了整個操作都是不安全的之外)某宪。指針的生命周期受制于閉包的生命周期仿村。正如文檔所說:
留意:字節(jié)指針參數不應該被存儲,或者在所調用閉包的生命周期以外被使用兴喂。
泛型解決方案
現在蔼囊,我們已經可以讀取原始字節(jié)數據焚志,并把它們轉換成正確的類型了。接下來創(chuàng)建一個通用的方法來更輕松地執(zhí)行操作畏鼓,而不用額外地關心語法酱酬。 另外,我們暫時還無法針對數據的子序列執(zhí)行操作云矫,而只能對整個 Data
實例執(zhí)行操作膳沽。 泛型的解決方法大概是這個樣子的:
extension Data {
func scanValue<T>(start: Int, length: Int) -> T {
return self.subdata(in: start..<start+length).withUnsafeBytes { $0.pointee }
}
}
let data = Data(bytes: [0x01, 0x02, 0x01, 0x02])
let a: Int16 = data.scanValue(start: 0, length: 1)
print(a)
// : 1
與之前的代碼相比,存在兩個顯著的不同點:
- 我們使用了
subdata
把掃描的字節(jié)限定于所需的特定區(qū)域让禀。 - 我們使用了泛型來支持提取不同的數據類型挑社。
數據轉換
另一方面,從現有的變量內容里得到 Data
緩沖堆缘, 雖然與下面的 Doom 的例子不相關滔灶,但是非常容易實現,(因此也寫在這里啦)
var variable = 256
let data = Data(buffer: UnsafeBufferPointer(start: &variable, count: 1))
print(data) // : <00010000 00000000>
解析 Doom WAD 文件
我小時候非常熱愛 Doom(毀滅戰(zhàn)士)這個游戲吼肥。也玩到了很高的等級录平,并修改 WAD 文件加入了新的精靈,紋理等缀皱。因此當我想給解析二進制文件找一個合適(和簡單)的例子時斗这,就想起了 WAD 文件的設計。因為它十分直觀且容易實現啤斗。于是我寫了一個簡單的小程序表箭,用于讀取 WAD 文件,然后列出所有存儲地板的紋理名稱 <a id="fnr.4" name="fnr.4" class="footref" href="#fn.4">4</a>钮莲。
我把源代碼 放在了 GitHub 免钻。
以下兩個文件解釋了Doom WAD 文件的設計。
但是對于這個簡單的示例崔拥,只需要了解部分的文件格式就夠了极舔。
首先,每個 WAD 文件都有頭文件:
數據類型 | 大小 | 功能 |
---|---|---|
Char | 4 | 字符串 IWAD 或者 PWAD |
Int32 | 4 | WAD 中區(qū)塊的數目 |
Int32 | 4 | 指向目錄位置的指針 |
開頭的 4 字節(jié)用來確定文件格式。 IWAD
表明是官方的 Doom WAD 文件,PWAD
表明是在運行時補充內容到主要 WAD 文件的補丁文件血筑。我們的應用只會讀取 IWAD
文件由缆。接著的 4 字節(jié)確定了 WAD 文件中 區(qū)塊(lump) 的數目。 區(qū)塊(Lump)是與 Doom 引擎合作的個體項目,例如紋理材質、精靈幀(Sprite-Frames),文字內容卖子,模型,等等刑峡。每個紋理都是不同類的區(qū)塊揪胃。最后的 4 字節(jié)定義了目錄的位置璃哟。我們開始解析目錄的時候,會給出相關解釋喊递。首先随闪,讓我們來解析頭文件。
解析頭文件
讀取 WAD 文件的方法非常簡單:
let data = try Data(contentsOf: wadFileURL, options: .alwaysMapped)
我們獲取到數據之后骚勘,首先需要解析頭文件铐伴。這里多次使用了之前創(chuàng)建的 scanValue
data`` 擴展。
public func validateWadFile() throws {
// 一些 Wad 文件定義
let wadMaxSize = 12, wadLumpsStart = 4, wadDirectoryStart = 8, wadDefSize = 4
// WAD 文件永遠以 12 字節(jié)的頭文件開始俏讹。
guard data.count >= wadMaxSize else { throw WadReaderError.invalidWadFile(reason: "File is too small") }
// 它包含了三個值:
// ASCII 字符 "IWAD" 或 "PWAD" 定義了 WAD 是 IWAD 還是 PWAD当宴。
let validStart = "IWAD".data(using: String.Encoding.ascii)!
guard data.subdata(in: 0..<wadDefSize) == validStart else
{ throw WadReaderError.invalidWadFile(reason: "Not an IWAD") }
// 一個聲明了 WAD 中區(qū)塊數目的整數。
let lumpsInteger: Int32 = data.scanValue(start: wadLumpsStart, length: wadDefSize)
// 一個整數泽疆,含有指向目錄地址的指針户矢。
let directoryInteger: Int32 = data.scanValue(start: wadDirectoryStart, length: wadDefSize)
guard lumpsInteger > 0 && directoryInteger > Int32(wadMaxSize)
else {
throw WadReaderError.invalidWadFile(reason: "Empty Wad File")
}
}
你可以在 GitHub 找到其他的類型(例如 WadReaderError
enum
)。下一步就是解析目錄來獲取每個區(qū)塊的地址和大小殉疼。
解析目錄
目錄與區(qū)塊的名字梯浪、包含的數據相關聯。它包括了一系列的項目瓢娜,每個項目的長度為 16 字節(jié)挂洛。目錄的長度取決于 WAD 頭文件里給出的數字。
每個 16 字節(jié)的項目按照以下的格式:
數據類型 | 大小 | 功能 |
---|---|---|
Int32 | 4 | 區(qū)塊數據在文件中的開始 |
Int32 | 4 | 區(qū)塊的字節(jié)大小 |
Char | 4 | 定義了區(qū)塊名字的 ASCII 字符串 |
名字的字符定義得比較復雜眠砾。文檔是這么說的:
使用 ASCII 字符串定義區(qū)塊的名字虏劲。區(qū)塊的名字只能使用 A-Z(大寫),0-9褒颈,[ ] - _(Arch-Vile 精靈除外柒巫,它們使用 \)。如果這串字符小于 8 字節(jié)長度谷丸,那么余下字節(jié)要被 null 填滿堡掏。
留意最后一句話。在 C 語言里淤井,字符串由空字符(\0
)結束布疼。這向系統表明了該字符串的內存到這里結束摊趾。Doom 用可選的空字符來節(jié)約存儲空間币狠。當字符串小于 8 字節(jié),它會包含一個空字符砾层。如果它達到最大允許長度( 8 字節(jié))漩绵,那么字符串以最后一個字節(jié)結束,而非由空字符結束肛炮。
? | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ? |
---|---|---|---|---|---|---|---|---|---|
短 | I | M | P | \0 | \0 | \0 | \0 | \0 | # |
長 | F | L | O | O | R | 4 | _ | 5 | # |
看看上面的表格止吐, 短名字會在字符串最后補空字符(位置 3)宝踪。長名字則沒有空字符,而是以 FLOOR4_5 的最后一個字符 5 作為結束碍扔。#
表明了下一個項目/片段在內存中的開始瘩燥。
在我們嘗試支持區(qū)塊的名字字符格式之前,首先處理一下簡單的部分不同。那就是讀取開頭和大小厉膀。
在開始之前,我們應該定義一個數據結構二拐,用于保存從目錄里讀取的內容:
public struct Lump {
public let filepos: Int32
public let size: Int32
public let name: String
}
然后服鹅,從完整的數據實例里取出數據片段,這是這些數據構成我們的目錄百新。
// 定義一個目錄項的默認大小企软。
let wadDirectoryEntrySize = 16
// 從完整數據里提取目錄片段。
let directory = data.subdata(in: Int(directoryLocation)..<(Int(directoryLocation) + Int(numberOfLumps) * wadDirectoryEntrySize))
接著饭望,我們以每段 16 字節(jié)的長度在 Data
中迭代仗哨。 Swift 的 stride
方法能夠很好地實現這個功能:
for currentIndex in stride(from: 0, to: directory.count, by: wadDirectoryEntrySize) {
let currentDirectoryEntry = directory.subdata(in: currentIndex..<currentIndex+wadDirectoryEntrySize)
// 一個整數表明區(qū)塊數據的起始在文件中的位置。
let lumpStart: Int32 = currentDirectoryEntry.scanValue(start: 0, length: 4)
// 一個表示了區(qū)塊字節(jié)大小的整數杰妓。
let lumpSize: Int32 = currentDirectoryEntry.scanValue(start: 4, length: 4)
...
}
簡單的部分到此結束藻治,下面我們要開始進入秋名山飆車了。
解析 C 字符串
要知道對于每個區(qū)塊的名字巷挥,每當遇到空的結束字符或者達到 8 字節(jié)的時候桩卵,我們都要停止向 Swift 字符串的寫入。首要任務是利用相關數據創(chuàng)建一個數據片段倍宾。
let nameData = currentDirectoryEntry.subdata(in: 8..<16)
Swift 給 C 字符串提供了很好的互操作性雏节。這意味著需要創(chuàng)建一個字符串的時候,我們只需要把數據交給 String
的初始化方法就行了:
let lumpName = String(data: nameData, encoding: String.Encoding.ascii)
這個方法可以執(zhí)行高职,但是結果并不正確钩乍。因為它忽略了空結束符,所以即使是短名字怔锌,也會跟長名字一樣轉換成 8 字節(jié)的字符串寥粹。例如,名字為 IMP 的區(qū)塊會變成 IMP00000埃元。但是由于 String(data:encoding:)
并不知道 Doom 把剩下的 5 字節(jié)都用空字符填滿了涝涤,而是根據 nameData
創(chuàng)建了一個完整 8 字節(jié)的字符串。
如果我們想要支持空字符岛杀, Swift 提供了一個 cString
初始化方法阔拳,用來讀取包含空結束符的有效 cString:
// 根據所給的 C 數組創(chuàng)建字符串
// 根據所給的編碼方式編碼
init?(cString: UnsafePointer<CChar>, encoding enc: String.Encoding)
留意這里的參數不需要傳入 data
實例,而是要求一個指向 CChars
的 unsafePointer类嗤。我們已經熟悉這個方法了糊肠,來寫一下:
let lumpName2 = nameData.withUnsafeBytes({ (pointer: UnsafePointer<UInt8>) -> String? in
return String(cString: UnsafePointer<CChar>(pointer), encoding: String.Encoding.ascii)
})
以上方法依然不能得到我們想要的結果辨宠。在 Doom 的名字長度小于 8 字符的情況下,這段代碼都能完美運行货裹。但是只要某個名字長度達到 8 字節(jié)而沒有一個空結束符時嗤形,這會繼續(xù)讀取(變成一個 16 字節(jié)片段)弧圆,直到找到下一個有效的空結束符派殷。 這就帶來一些不確定長度的長字符串。
這個邏輯是 Doom 自定義的墓阀,因此我們需要自己來實現相應的代碼毡惜。Data
支持 Swift 的集合和序列操作,因此我們可以直接用 reduce 來解決斯撮。
let lumpName3Bytes = try nameData.reduce([UInt8](), { (a: [UInt8], b: UInt8) throws -> [UInt8] in
guard b > 0 else { return a }
guard a.count <= 8 else { return a }
return a + [b]
})
guard let lumpName3 = String(bytes: lumpName3Bytes, encoding: String.Encoding.ascii)
else {
throw WadReaderError.invalidLup(reason: "Could not decode lump name for bytes \(lumpName3Bytes)")
}
這段代碼把數據以 UInt8
字節(jié) reduce经伙,并檢查數據是否含有提前的空結束符。一切工作正常勿锅,雖然數據需要進行幾次抽象帕膜,執(zhí)行速度并不是很快。
不過如果我們能以 Doom 引擎類似的方法來解決的話溢十,效果會更好垮刹。Doom 僅移動了 char*
的指針,并根據字符是否為空結束符判斷是否需要提前跳出张弛。Doom 是用 C 語言寫的荒典,因此它能在裸指針層面上迭代。
那么我們要怎樣在 Swift 里實現這個邏輯呢吞鸭?事實上寺董,可以再次借助 withUnsafeBytes
實現類似的效果。來看看代碼:
let finalLumpName = nameData.withUnsafeBytes({ (pointer: UnsafePointer<CChar>) -> String? in
var localPointer = pointer
for _ in 0..<8 {
guard localPointer.pointee != CChar(0) else { break }
localPointer = localPointer.successor()
}
let position = pointer.distance(to: localPointer)
return String(data: nameData.subdata(in: 0..<position),
encoding: String.Encoding.ascii)
})
guard let lumpName4 = finalLumpName else {
throw WadReaderError.invalidLup(reason: "Could not decode lump name for bytes \(lumpName3Bytes)")
}
withUnsafeBytes
的用法與之前相似刻剥,我們接受一個指向原始內存的指針遮咖。 指針
是一個 let
常數,但是由于我們需要對它做修改造虏,因此我們在第一行創(chuàng)建了一個可變的拷貝<a id="fnr.5" name="fnr.5" class="footref" href="#fn.5">5</a>御吞。
接著,開始我們的主要工作漓藕。從 0 到 8 循環(huán)陶珠,每次循環(huán)都檢測指針指向的字符(pointee
)是否為空結束符(CChar(0)
)。是空結束符的話就表明提前找到了空結束符撵术,需要跳出循環(huán)背率。否則將 localPointer
重載為下一位话瞧,即就是嫩与,當前指針內存中的下一個位置寝姿。這樣,我們就能逐字節(jié)地讀取內存中的所有內容了划滋。
完成之后 饵筑,就計算一下我們原始指針
和本地指針
的距離。如果在找到空結束符之前我們僅前移了三次处坪,那么兩個指針之前的距離為 3根资。最后,這個距離能讓我們通過實際 C 字符串的子數據創(chuàng)建一個新的 String 實例同窘。
最后用得到的數據創(chuàng)建新的 區(qū)塊
結構體:
lumps.append(Lump(filepos: lumpStart, size: lumpSize, name: lumpName4))
如果你觀察源代碼玄帕,會發(fā)現 F_START
和 F_END
這種顯著的引用。對于特殊的 區(qū)塊區(qū)域 想邦,Doom 使用特殊名稱的空區(qū)塊標記了區(qū)域的開頭和結尾裤纹。F_START / F_END
圍起了所有地板紋理的區(qū)塊。在本教程中丧没,我們將忽略這額外的一步鹰椒。
應用最終的截圖:
我知道這看起來并不酷炫。之后可能會計劃在博客里寫寫如何展示那些紋理呕童。
橋接 NSData
我發(fā)現新的 Data
比 NSData
使用起來更加方便漆际。然而,如果你需要 NSData
或者 getBytes
方法的話夺饲,這有一個簡單的方法能把 Data
轉換成 NSData
奸汇。Swift 文檔是這么寫的:
Data 具有“寫時拷貝”能力,也能與 Objective-C 的 NSData 類型橋接往声。 對于 NSData 的自定義子類茫蛹,你可以使用
myData as Data
把它的一個實例轉換成結構體 Data 。
// 創(chuàng)建一個 Data 結構體
let aDataStruct = Data()
// 獲得底層的引用類型 NSData
let aDataReference = aDataStruct as NSData
無論何時烁挟,如果你覺得 Data
類型難以滿足你的需求婴洼,都能輕松地回到 NSData
類型使用你熟悉的方法。不過總而言之你還是應該盡可能地使用新的 Data
類型(除非你需要引用類型的語法)撼嗓。
<a id="fn.1" name="fn.1" class="footnum" href="#fnr.1">1: 有些類型(例如 Date
) 并不是包裹類型柬采,而是全新的實現。</a>
<a id="fn.2" name="fn.2" class="footnum" href="#fnr.2">2: Doom1且警,Doom2粉捻,Hexen,Heretic斑芜,還有 Ultimate Doom肩刃。雖然我只在 Doom1 Shareware 驗證過。</a></sup
<a id="fn.3" name="fn.3" class="footnum" href="#fnr.3">3: 留意,我們并沒有驗證最開頭的 4 個字節(jié)盈包,確保這的確是 ABCD 文件沸呐。但是要添加這個驗證也很簡單。</a></sup
<a id="fn.4" name="fn.4" class="footnum" href="#fnr.4">4: 其實我也想展示 texture 但是不夠時間去實現呢燥。</a></sup
<a id="fn.5" name="fn.5" class="footnum" href="#fnr.5">5: Swift 3 不再在閉包和函數體里支持有用的 var
標注崭添。</a></sup
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權叛氨,最新文章請訪問 http://swift.gg呼渣。