作者:Terhechte,原文鏈接,原文日期:2016/07/15
譯者:BigbigChai;校對:way;定稿:千葉知風
Swift 3 : 從 NSData 到 Data 的轉變
Swift 3 帶來了許多大大小小的變化。其中一個是為常見的 Foundation 引用類型(例如將 NSData 封裝成 Data
,將 NSDate 封裝成 Date
)添加值類型的封裝。這些新類型除了改變了內存行為和名字以外,在方法上也與對應的引用類型有所區別 <a id="fnr.1" name="fnr.1" class="footref" href="#fn.1">1</a>。 從更換新方法名這類小改動,到完全去掉某一功能這種大改動,我們需要一些時間去適應這些新的值類型。本文會重點介紹作為值類型的 Data
是如何封裝 NSData
的。
不僅如此,在學習完基礎知識之后,我們還會寫一個簡單的示例應用。這個應用會讀取和解析一個 Doom 毀滅戰士的 WAD 文件 <a id="fnr.2" name="fnr.2" class="footref" href="#fn.2">2</a>。
基本區別
對于 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
的方法,可以發現另一個變化。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
類型的字節內容了。我認為這是相對 NSData
的一大進步。優勢在于,現在可以輕松地使用下標以及對數據內容進行比較了。
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)
獲取字節
如果你使用 Data
與底層代碼(例如 C
庫)交互,你會發現另一個明顯的區別:Data
缺少了 NSData
的 getBytes
方法:
// NSData
func getBytes(_ buffer: UnsafeMutablePointer<Void>, length length: Int)
getBytes
方法有許多不同的應用場景。其中最常見的是,當你需要解析一個文件并按字節讀取并存儲到數據類型/變量里。例如說,你想讀取一個包含項目列表的二進制文件。這個文件經過編碼,而編碼方式如下:
數據類型 | 大小 | 功能 |
---|---|---|
Char | 4 | 頭部 (ABCD) |
UInt32 | 4 | 數據開始 |
UInt32 | 4 | 數量 |
該文件包含了一個四字節字符串 ABCD 標簽,用來表示正確的文件類型(做校驗)。接著的四字節定義了實際數據(例如頭部的結束和項目的開始),頭部最后的四字節定義了該文件存儲項目的數量。
用 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 字符串,方法會更簡單。你可以直接用正確的字段定義一個 結構體
,然后把字節讀到結構體里:
數據類型 | 大小 | 功能 |
---|---|---|
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 這個功能不再可用,轉而提供了一個新方法作替代:
// 從數據里獲得字節
func withUnsafeBytes<ResultType, ContentType>((UnsafePointer<ContentType>) -> ResultType)
通過這個方法,我們可以從閉包中直接讀取數據的字節內容。來看一個簡單的例子:
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
如你所見,我們創建了一個字節的 Data
實例,通過在閉包里定義 UnsafePointer<Int32>
,返回 Int32
類型的數據。可以把代碼寫得再精簡一點,因為編譯器能夠根據上下文推斷結果類型:
let result: Int32 = data.withUnsafeBytes { $0.pointee }
數據的生命周期
使用 withUnsafeBytes
時,指針(你所訪問的)的生命周期是一個很重要的考慮因素(除了整個操作都是不安全的之外)。指針的生命周期受制于閉包的生命周期。正如文檔所說:
留意:字節指針參數不應該被存儲,或者在所調用閉包的生命周期以外被使用。
泛型解決方案
現在,我們已經可以讀取原始字節數據,并把它們轉換成正確的類型了。接下來創建一個通用的方法來更輕松地執行操作,而不用額外地關心語法。 另外,我們暫時還無法針對數據的子序列執行操作,而只能對整個 Data
實例執行操作。 泛型的解決方法大概是這個樣子的:
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
把掃描的字節限定于所需的特定區域。 - 我們使用了泛型來支持提取不同的數據類型。
數據轉換
另一方面,從現有的變量內容里得到 Data
緩沖, 雖然與下面的 Doom 的例子不相關,但是非常容易實現,(因此也寫在這里啦)
var variable = 256
let data = Data(buffer: UnsafeBufferPointer(start: &variable, count: 1))
print(data) // : <00010000 00000000>
解析 Doom WAD 文件
我小時候非常熱愛 Doom(毀滅戰士)這個游戲。也玩到了很高的等級,并修改 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 中區塊的數目 |
Int32 | 4 | 指向目錄位置的指針 |
開頭的 4 字節用來確定文件格式。 IWAD
表明是官方的 Doom WAD 文件,PWAD
表明是在運行時補充內容到主要 WAD 文件的補丁文件。我們的應用只會讀取 IWAD
文件。接著的 4 字節確定了 WAD 文件中 區塊(lump) 的數目。 區塊(Lump)是與 Doom 引擎合作的個體項目,例如紋理材質、精靈幀(Sprite-Frames),文字內容,模型,等等。每個紋理都是不同類的區塊。最后的 4 字節定義了目錄的位置。我們開始解析目錄的時候,會給出相關解釋。首先,讓我們來解析頭文件。
解析頭文件
讀取 WAD 文件的方法非常簡單:
let data = try Data(contentsOf: wadFileURL, options: .alwaysMapped)
我們獲取到數據之后,首先需要解析頭文件。這里多次使用了之前創建的 scanValue
data`` 擴展。
public func validateWadFile() throws {
// 一些 Wad 文件定義
let wadMaxSize = 12, wadLumpsStart = 4, wadDirectoryStart = 8, wadDefSize = 4
// WAD 文件永遠以 12 字節的頭文件開始。
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 中區塊數目的整數。
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
)。下一步就是解析目錄來獲取每個區塊的地址和大小。
解析目錄
目錄與區塊的名字、包含的數據相關聯。它包括了一系列的項目,每個項目的長度為 16 字節。目錄的長度取決于 WAD 頭文件里給出的數字。
每個 16 字節的項目按照以下的格式:
數據類型 | 大小 | 功能 |
---|---|---|
Int32 | 4 | 區塊數據在文件中的開始 |
Int32 | 4 | 區塊的字節大小 |
Char | 4 | 定義了區塊名字的 ASCII 字符串 |
名字的字符定義得比較復雜。文檔是這么說的:
使用 ASCII 字符串定義區塊的名字。區塊的名字只能使用 A-Z(大寫),0-9,[ ] - _(Arch-Vile 精靈除外,它們使用 \)。如果這串字符小于 8 字節長度,那么余下字節要被 null 填滿。
留意最后一句話。在 C 語言里,字符串由空字符(\0
)結束。這向系統表明了該字符串的內存到這里結束。Doom 用可選的空字符來節約存儲空間。當字符串小于 8 字節,它會包含一個空字符。如果它達到最大允許長度( 8 字節),那么字符串以最后一個字節結束,而非由空字符結束。
? | 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 作為結束。#
表明了下一個項目/片段在內存中的開始。
在我們嘗試支持區塊的名字字符格式之前,首先處理一下簡單的部分。那就是讀取開頭和大小。
在開始之前,我們應該定義一個數據結構,用于保存從目錄里讀取的內容:
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 字節的長度在 Data
中迭代。 Swift 的 stride
方法能夠很好地實現這個功能:
for currentIndex in stride(from: 0, to: directory.count, by: wadDirectoryEntrySize) {
let currentDirectoryEntry = directory.subdata(in: currentIndex..<currentIndex+wadDirectoryEntrySize)
// 一個整數表明區塊數據的起始在文件中的位置。
let lumpStart: Int32 = currentDirectoryEntry.scanValue(start: 0, length: 4)
// 一個表示了區塊字節大小的整數。
let lumpSize: Int32 = currentDirectoryEntry.scanValue(start: 4, length: 4)
...
}
簡單的部分到此結束,下面我們要開始進入秋名山飆車了。
解析 C 字符串
要知道對于每個區塊的名字,每當遇到空的結束字符或者達到 8 字節的時候,我們都要停止向 Swift 字符串的寫入。首要任務是利用相關數據創建一個數據片段。
let nameData = currentDirectoryEntry.subdata(in: 8..<16)
Swift 給 C 字符串提供了很好的互操作性。這意味著需要創建一個字符串的時候,我們只需要把數據交給 String
的初始化方法就行了:
let lumpName = String(data: nameData, encoding: String.Encoding.ascii)
這個方法可以執行,但是結果并不正確。因為它忽略了空結束符,所以即使是短名字,也會跟長名字一樣轉換成 8 字節的字符串。例如,名字為 IMP 的區塊會變成 IMP00000。但是由于 String(data:encoding:)
并不知道 Doom 把剩下的 5 字節都用空字符填滿了,而是根據 nameData
創建了一個完整 8 字節的字符串。
如果我們想要支持空字符, Swift 提供了一個 cString
初始化方法,用來讀取包含空結束符的有效 cString:
// 根據所給的 C 數組創建字符串
// 根據所給的編碼方式編碼
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 字節而沒有一個空結束符時,這會繼續讀取(變成一個 16 字節片段),直到找到下一個有效的空結束符。 這就帶來一些不確定長度的長字符串。
這個邏輯是 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
字節 reduce,并檢查數據是否含有提前的空結束符。一切工作正常,雖然數據需要進行幾次抽象,執行速度并不是很快。
不過如果我們能以 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
常數,但是由于我們需要對它做修改,因此我們在第一行創建了一個可變的拷貝<a id="fnr.5" name="fnr.5" class="footref" href="#fn.5">5</a>。
接著,開始我們的主要工作。從 0 到 8 循環,每次循環都檢測指針指向的字符(pointee
)是否為空結束符(CChar(0)
)。是空結束符的話就表明提前找到了空結束符,需要跳出循環。否則將 localPointer
重載為下一位,即就是,當前指針內存中的下一個位置。這樣,我們就能逐字節地讀取內存中的所有內容了。
完成之后 ,就計算一下我們原始指針
和本地指針
的距離。如果在找到空結束符之前我們僅前移了三次,那么兩個指針之前的距離為 3。最后,這個距離能讓我們通過實際 C 字符串的子數據創建一個新的 String 實例。
最后用得到的數據創建新的 區塊
結構體:
lumps.append(Lump(filepos: lumpStart, size: lumpSize, name: lumpName4))
如果你觀察源代碼,會發現 F_START
和 F_END
這種顯著的引用。對于特殊的 區塊區域 ,Doom 使用特殊名稱的空區塊標記了區域的開頭和結尾。F_START / F_END
圍起了所有地板紋理的區塊。在本教程中,我們將忽略這額外的一步。
應用最終的截圖:
我知道這看起來并不酷炫。之后可能會計劃在博客里寫寫如何展示那些紋理。
橋接 NSData
我發現新的 Data
比 NSData
使用起來更加方便。然而,如果你需要 NSData
或者 getBytes
方法的話,這有一個簡單的方法能把 Data
轉換成 NSData
。Swift 文檔是這么寫的:
Data 具有“寫時拷貝”能力,也能與 Objective-C 的 NSData 類型橋接。 對于 NSData 的自定義子類,你可以使用
myData as Data
把它的一個實例轉換成結構體 Data 。
// 創建一個 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 個字節,確保這的確是 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。