macOS 動態桌面

SwiftGG翻譯組發表於2018-10-25

作者:Mattt,原文連結,原文日期:2018-10-01 譯者:saitjr;校對:冬瓜Yousanflics;定稿:Forelax

Dark Mode(深色模式)可謂是 macOS 最受歡迎的特性之一了 —— 尤其是對於你我這樣的開發者來說。我們不僅喜歡文字編輯器是暗色的主題,還很看中整個系統色調的一致性。

過去幾年,和這個特性旗鼓相當的要數 Night Shift(夜覽),它主要是在日夜更替的時候減少對眼睛的勞損。

縱觀這兩個功能,Dynamic Desktop(動態桌面)也就呼之欲出了,當然這也是 Mojave 的新特性之一。進入“系統偏好設定 > 桌面與螢幕保護程式” 並且選擇“動態”,就能得到一個基於地理位置且全天候動態變化的桌布。

macOS 動態桌面

效果不僅微妙,而且讓人愉悅。桌面彷彿被賦予了生命,能隨著時間的推移而變化;符合自然規律。(不出意外的話,結合 dark mode 的切換,還會有討喜的特效)

這到底是如何實現的呢?
這便是本週 NSHipster 討論的問題。

答案會深入探究圖片格式,同時涉及一些逆向工程以及球面三角學相關的內容。




理解 Dynamic Desktop 第一步,就是要找到這些動態圖片。

在 macOS Mojave 系統下,開啟訪達,選擇“前往 > 前往資料夾...” (⇧⌘G),輸入“/Library/Desktop Pictures/”。

macOS 動態桌面

在這個目錄下,可以找到名為“Mojave.heic”的檔案。雙擊通過預覽開啟。

macOS 動態桌面

在預覽中,左邊欄會顯示從 1~16 的縮圖,每張都是不同狀態的沙漠圖。

macOS 動態桌面

如果選擇“工具 > 顯示檢查器”(⌘I),可以看到更為詳細的資訊,如下圖所示:

macOS 動態桌面

不幸的是,這些就是預覽所展示的全部資訊了(截至發稿前)。即使點選旁邊的“更多資訊檢查器”,我們也只是能得到下面這個表格,其餘的無從得知:

Color Model RGB
Depth: 8
Pixel Height 2,880
Pixel Width 5,120
Profile Name Display P3

字尾 .heic 表示圖片容器採用 HFIF(High-Efficiency Image File Format)編碼,即高效率圖檔格式(這種格式基於 HEVC(High-Efficiency Video Compression),即高效率視訊壓縮,也就是 H.265)。更多資訊,可以參考 WWDC 2017 Session 503 "Introducing HEIF and HEVC"

想要獲得更多的資料,我們還需要腳踏實地,真真切切的深入底層 API。

利用 CoreGraphics 一探究竟

第一步先建立 Xcode Playground。簡單起見,我們將“Mojave.heic”檔案路徑硬編碼到程式碼中。

import Foundation
import CoreGraphics

// 系統版本要求 macOS 10.14 Mojave
let url = URL(fileURLWithPath: "/Library/Desktop Pictures/Mojave.heic")
複製程式碼

然後,建立 CGImageSource,拷貝後設資料並遍歷全部標籤:

let source = CGImageSourceCreateWithURL(url as CFURL, nil)!
let metadata = CGImageSourceCopyMetadataAtIndex(source, 0, nil)!
let tags = CGImageMetadataCopyTags(metadata) as! [CGImageMetadataTag]
for tag in tags {
    guard let name = CGImageMetadataTagCopyName(tag),
        let value = CGImageMetadataTagCopyValue(tag)
    else {
        continue
    }

    print(name, value)
}
複製程式碼

執行這段程式碼,會得到兩個值:一個是 hasXMP,值為 "True",另一個是 solar,它的值是一串看不大懂的資料:

YnBsaXN0MDDRAQJSc2mvEBADDBAUGBwgJCgsMDQ4PEFF1AQFBgcICQoLUWlRelFh
UW8QACNAcO7vOubr3yO/1e+pmkOtXBAB1AQFBgcNDg8LEAEjQFRxqCKOFiAjwCR6
waUkDgHUBAUGBxESEwsQAiNAVZV4BI4c+CPAEP2uFrMcrdQEBQYHFRYXCxADI0BW
tALKmrjwIz/2ObLnx6l21AQFBgcZGhsLEAQjQFfTrJlEjnwjQByrLle1Q0rUBAUG
Bx0eHwsQBSNAWPrrmI0ISCNAKiwhpSRpc9QEBQYHISIjCxAGI0BgJff9KDpyI0BE
NTOsilht1AQFBgclJicLEAcjQGbHdYIVQKojQEq3fAg86lXUBAUGBykqKwsQCCNA
bTGmpC2YRiNAQ2WFOZGjntQEBQYHLS4vCxAJI0BwXfII2B+SI0AmLcjfuC7g1AQF
BgcxMjMLEAojQHCnF6YrsxcjQBS9AVBLTq3UBAUGBzU2NwsQCyNAcTcSnimmjCPA
GP5E0ASXJtQEBQYHOTo7CxAMI0BxgSADjxK2I8AoalieOTyE1AQFBgc9Pj9AEA0j
QHNWsnnMcWIjwEO+oq1pXr8QANQEBQYHQkNEQBAOI0ABZpkFpAcAI8BKYGg/VvMf
1AQFBgdGR0hAEA8jQErBKblRzPgjwEMGElBIUO0ACAALAA4AIQAqACwALgAwADIA
NAA9AEYASABRAFMAXABlAG4AcAB5AIIAiwCNAJYAnwCoAKoAswC8AMUAxwDQANkA
4gDkAO0A9gD/AQEBCgETARwBHgEnATABOQE7AUQBTQFWAVgBYQFqAXMBdQF+AYcB
kAGSAZsBpAGtAa8BuAHBAcMBzAHOAdcB4AHpAesB9AAAAAAAAAIBAAAAAAAAAEkA
AAAAAAAAAAAAAAAAAAH9
複製程式碼

太陽之光

大多數人看到這串文字,就會默默合上 MacBook Pro,大呼告辭。但一定有人發現,這串文字非常像 Base64 編碼 的傑作。

讓我們來驗證一下這個假設:

if name == "solar" {
    let data = Data(base64Encoded: value)!
    print(String(data: data, encoding: .ascii))
}
複製程式碼

              bplist00Ò\u{01}\u{02}\u{03}...

這又是什麼?bplist 後面接了一串亂碼?

天哪,原來這是 二進位制屬性列表檔案簽名

利用 PropertyListSerialization 來看看呢...

if name == "solar" {
    let data = Data(base64Encoded: value)!
    let propertyList = try PropertyListSerialization
                            .propertyList(from: data,
                                          options: [],
                                          format: nil)
    print(propertyList)
}
複製程式碼
(
    ap = {
        d = 15;
        l = 0;
    };
    si = (
        {
            a = "-0.3427528387535028";
            i = 0;
            z = "270.9334057827345";
        },
        ...
        {
            a = "-38.04743388682423";
            i = 15;
            z = "53.50908581251309";
        }
    )
)
複製程式碼

清晰多了!

首先有兩個一級鍵:

ap 鍵對應的值是包含 dl 兩個鍵的字典,它們的值都是整型。

si 鍵對應的值是包含多個字典的陣列,字典中有整型,也有浮點型的值。在巢狀的字典中,i 最容易理解:它從 0 一直遞增到 15,這表示的是圖片序列的下標。在沒有更多資訊的情況下,很難猜測 az 的含義,其實它們表示相應圖片中太陽的高度(a)和方位角(z)。

計算太陽的位置

就在我落筆之時,身處北半球的人正在進入秋季,白晝變短,氣溫變低,而南半球的人卻經歷著白晝變長,氣溫變高。季節的變化告訴我們,日照的時長取決於你在星球上的位置,以及星球繞太陽的軌道。

可喜的是,天文學家能告訴你 —— 而且相當準確 —— 太陽在天空中的位置或時間。不可賀的是,這其中的計算十分 複雜

但老實講,我們並不用過分深究它,在網上能找到相關的程式碼。經過不斷的試錯,它們就能為我所用(歡迎 PR!):

import Foundation
import CoreLocation

// 位於加州庫比蒂諾的 Apple Park
let location = CLLocation(latitude: 37.3327, longitude: -122.0053)
let time = Date()

let position = solarPosition(for: location, at: time)
let formattedDate = DateFormatter.localizedString(from: time,
                                                    dateStyle: .medium,
                                                    timeStyle: .short)
print("Solar Position on \(formattedDate)")
print("\(position.azimuth)° Az / \(position.elevation)° El")
複製程式碼

Solar Position on Oct 1, 2018 at 12:00 180.73470025840783° Az / 49.27482549913847° El

2018 年 10 月 1 日中午,太陽從南面照射在 Apple Park,大約處於地平線中間,直射頭頂。

如果繪製出太陽一天的位置,我們可以得到一個正弦曲線,這不禁讓人聯想到 Apple Watch 的“太陽錶盤”。

macOS 動態桌面

擴充套件對 XMP 的理解

好吧,天文學到此結束。接下來是一個乏味的過程:擺在眼前的 XML 後設資料。

還記得之前的後設資料鍵 hasXMP 嗎?對,就是它沒錯。

XMP(Extensible Metadata Platform),即可擴充套件後設資料平臺,是一種使用後設資料標記檔案的標準格式。XMP 長什麼樣呢?請打起精神來:

let xmpData = CGImageMetadataCreateXMPData(metadata, nil)
let xmp = String(data: xmpData as! Data, encoding: .utf8)!
print(xmp)
複製程式碼
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
      <rdf:Description rdf:about=""
            xmlns:apple_desktop="http://ns.apple.com/namespace/1.0/">
         <apple_desktop:solar>
            <!-- (Base64-Encoded Metadata) -->
        </apple_desktop:solar>
      </rdf:Description>
   </rdf:RDF>
</x:xmpmeta>
複製程式碼

嘔。

不過也幸好我們檢查了一下。之後想要成功自定義 Dynamic Desktop,還得仰仗 apple_desktop 名稱空間。

既然如此,就開始吧。

建立自定義 Dynamic Desktop

首先,建立一個資料模型來表示 Dynamic Desktop:

struct DynamicDesktop {
    let images: [Image]

    struct Image {
        let cgImage: CGImage
        let metadata: Metadata

        struct Metadata: Codable {
            let index: Int
            let altitude: Double
            let azimuth: Double

            private enum CodingKeys: String, CodingKey {
                case index = "i"
                case altitude = "a"
                case azimuth = "z"
            }
        }
    }
}
複製程式碼

如前文所述,每個 Dynamic Desktop 都由一個有序的圖片序列構成,每個圖片又包含儲存在 CGImage 物件中的圖片資料和後設資料。Metadata 採用 Codable 型別,是為了編譯器自動合成相關函式。我們能在生成 Base64 編碼的二進位制屬性列表時感受到它的優勢。

寫入圖片目標

首先,建立一個指定輸出 URL 的 CGImageDestination。檔案型別為 heic,資源數量即需要包含的圖片張數。

guard let imageDestination = CGImageDestinationCreateWithURL(
                                outputURL as CFURL,
                                AVFileType.heic as CFString,
                                dynamicDesktop.images.count,
                                nil
                             )
else {
    fatalError("Error creating image destination")
}
複製程式碼

接著,遍歷動態桌面物件中的全部圖片。通過 enumerated() 方法,我們還能獲取到當前 index,這樣就可以在第一張圖片上設定圖片後設資料:

for (index, image) in dynamicDesktop.images.enumerated() {
    if index == 0 {
        let imageMetadata = CGImageMetadataCreateMutable()
        guard let tag = CGImageMetadataTagCreate(
                            "http://ns.apple.com/namespace/1.0/" as CFString,
                            "apple_desktop" as CFString,
                            "solar" as CFString,
                            .string,
                            try! dynamicDesktop.base64EncodedMetadata() as CFString
                        ),
            CGImageMetadataSetTagWithPath(
                imageMetadata, nil, "xmp:solar" as CFString, tag
            )
        else {
            fatalError("Error creating image metadata")
        }

        CGImageDestinationAddImageAndMetadata(imageDestination,
                                              image.cgImage,
                                              imageMetadata,
                                              nil)
    } else {
        CGImageDestinationAddImage(imageDestination,
                                   image.cgImage,
                                   nil)
    }
}
複製程式碼

除了較為繁雜的 Core Graphics API 以外,程式碼可以說非常直觀了。唯一需要進一步解釋的只有 CGImageMetadataTagCreate(_:_:_:_:_:)

由於圖片與後設資料容器的結構、程式碼的表現形式均不同,所以我們不得不為 DynamicDesktop 實現 Encodable 協議:

extension DynamicDesktop: Encodable {
    private enum CodingKeys: String, CodingKey {
        case ap, si
    }

    private enum NestedCodingKeys: String, CodingKey {
        case d, l
    }

    func encode(to encoder: Encoder) throws {
        var keyedContainer =
            encoder.container(keyedBy: CodingKeys.self)

        var nestedKeyedContainer =
            keyedContainer.nestedContainer(keyedBy: NestedCodingKeys.self,
                                           forKey: .ap)

        // FIXME:不確定此處 `l` 與 `d` 的含義
        try nestedKeyedContainer.encode(0, forKey: .l)
        try nestedKeyedContainer.encode(self.images.count, forKey: .d)

        var unkeyedContainer =
            keyedContainer.nestedUnkeyedContainer(forKey: .si)
        for image in self.images {
            try unkeyedContainer.encode(image.metadata)
        }
    }
}
複製程式碼

有了這個,就可以實現之前程式碼中提到的 base64EncodedMetadata() 方法了:

extension DynamicDesktop {
    func base64EncodedMetadata() throws -> String {
        let encoder = PropertyListEncoder()
        encoder.outputFormat = .binary

        let binaryPropertyListData = try encoder.encode(self)
        return binaryPropertyListData.base64EncodedString()
    }
}
複製程式碼

當 for-in 迴圈執行完,也就表明所有圖片和後設資料均被寫入,我們可以呼叫 CGImageDestinationFinalize(_:) 方法終止圖片源,並將圖片寫入磁碟。

guard CGImageDestinationFinalize(imageDestination) else {
    fatalError("Error finalizing image")
}
複製程式碼

如果一切順利,就可以為重新定義 Dynamic Desktop 的自己而感到驕傲了。棒!




我們非常喜歡 Mojave 的 Dynamic Desktop 特性,並且也很欣慰看到它彷彿重現了 Windows 95 桌布進入主流市場時的輝煌。

如果你也這樣想,下面還有些想法可供參考:

照片自動生成 Dynamic Desktop

讓人振奮的是,天體運動這樣高不可攀的研究,竟然可以簡化用二元方程來表達:時間與位置。

在之前的例子中,這部分資訊都是硬編碼的,但其實它們可以通過讀取圖片資料來自動獲取。

預設情況下,絕大部分手機的相機都會捕獲拍攝時的 Exif 後設資料。後設資料包含了照片拍攝的時間,以及當時裝置的 GPS 座標。

通過讀取後設資料中的時間與位置資訊,能自動獲取太陽的位置,那麼從一系列圖片中生成 Dynamic Desktop 也就順理成章了。

iPhone 上的延時攝影

想要好好利用手上全新的 iPhone Xs 嗎?(更確切的說,“在糾結賣不賣舊 iPhone 的時候,可以先用它來做些有創意的事?”)

將手機充上電,擺在窗前,開啟相機的延時攝影模式,點選“拍攝”按鈕。從最後的視訊中選出一些關鍵幀,就可以製作專屬 Dynamic Desktop 了。

當然,你可以看看 Skyflow 這類應用,它能設定時間間隔來拍攝靜態圖片。

通過 GIS 資料打造風景

如果你無法忍受手機一整天不在身邊(傷心),又或者沒什麼標誌性景象值得拍攝(依然傷心),你還可以創造一個屬於自己的世界(這比現實本身還要令人傷心)。

可以選擇用 Terragen 這類應用,它打造了一個逼真的 3D 世界,還能對太陽、地球、天空進行微調。

想要更加簡化,還可以從美國地質調查局的 國家地圖網站 上下載高程地圖,以用於 3D 渲染的模板。

下載預製的 Dynamic Desktops

再或者,你每天都非常多的工作要做,抽不出時間搗騰好看的圖片,也可以選擇付費從別人那裡購買。

我個人是 24 Hour Wallpaper 這款應用的粉絲。如果你有別的推薦,歡迎 聯絡我們



NSMUTABLEHIPSTER

疑問?糾錯?歡迎提 issuespull requests —— NSHipster 因你而變得更好。

本文用的是 Swift 4.2。關於站內文章的狀態資訊,可以檢視 狀態彙總頁面

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章