做者:Terhechte,原文連接,原文日期:2016/07/15
譯者:BigbigChai;校對:way;定稿:千葉知風git
Swift 3 帶來了許多大大小小的變化。其中一個是爲常見的 Foundation 引用類型(例如將 NSData 封裝成 Data
,將 NSDate 封裝成 Date
)添加值類型的封裝。這些新類型除了改變了內存行爲和名字之外,在方法上也與對應的引用類型有所區別 1。 從更換新方法名這類小改動,到徹底去掉某一功能這種大改動,咱們須要一些時間去適應這些新的值類型。本文會重點介紹做爲值類型的 Data
是如何封裝 NSData
的。github
不只如此,在學習完基礎知識以後,咱們還會寫一個簡單的示例應用。這個應用會讀取和解析一個 Doom 毀滅戰士的 WAD 文件 2。小程序
對於 NSData
,其中一個最多見的使用場景就是調用如下方法加載和寫入數據:swift
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閉包
Equatableapp
Hashabledom
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))
如此將返回正確結果3。若是數據不包含 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 這個功能再也不可用,轉而提供了一個新方法做替代:
// 從數據裏得到字節 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 文件加入了新的精靈,紋理等。所以當我想給解析二進制文件找一個合適(和簡單)的例子時,就想起了 WAD 文件的設計。由於它十分直觀且容易實現。因而我寫了一個簡單的小程序,用於讀取 WAD 文件,而後列出全部存儲地板的紋理名稱 4。
我把源代碼 放在了 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) ... }
簡單的部分到此結束,下面咱們要開始進入秋名山飆車了。
要知道對於每一個區塊的名字,每當遇到空的結束字符或者達到 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
常數,可是因爲咱們須要對它作修改,所以咱們在第一行建立了一個可變的拷貝5。
接着,開始咱們的主要工做。從 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
圍起了全部地板紋理的區塊。在本教程中,咱們將忽略這額外的一步。
應用最終的截圖:
我知道這看起來並不酷炫。以後可能會計劃在博客裏寫寫如何展現那些紋理。
我發現新的 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
類型(除非你須要引用類型的語法)。
1: 有些類型(例如 Date
) 並非包裹類型,而是全新的實現。
2: Doom1,Doom2,Hexen,Heretic,還有 Ultimate Doom。雖然我只在 Doom1 Shareware 驗證過。</sup
3: 留意,咱們並無驗證最開頭的 4 個字節,確保這的確是 ABCD 文件。可是要添加這個驗證也很簡單。</sup
4: 其實我也想展現 texture 可是不夠時間去實現。</sup
5: Swift 3 再也不在閉包和函數體裏支持有用的 var
標註。</sup
本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg。