雲端 Mac 上的 iOS 動態 GIF 匯出驗收:影格時間、循環與讀回
同一段動畫,匯出的 GIF 雖然能開啟,卻可能在第一個影格停頓、提早結束循環,或是總播放時間變短。先別急著修改播放器。在雲端 Mac 上產生固定樣本,再用 ImageIO 讀回檔案,將「寫入有誤」與「播放方式不同」分開確認。以下指令碼只使用系統框架與 Swift 工具鏈,不必連接外部服務,也不涉及簽署。
先明確定義動畫契約
準備三個純色影格,尺寸統一為 16×16,顯示時間依序為 0.12、0.24、0.36 秒,循環欄位設為 0,表示無限循環。單次循環的預期時間為 0.72 秒。這些數值是測試輸入,不是效能量測結果。
GIF 以百分之一秒為單位記錄延遲時間。如果應用程式傳入更精細的時間值,應先訂好取整規則,再計算預期值;不要讓編碼器的隱含轉換替你決定。第一輪測試先選用能以此單位精確表示的數值,避免把量化誤差混入編碼問題。
檔案屬性檢查回答的是「實際寫入了什麼」;播放器檢查回答的是「使用者看到了什麼」。兩個層次都通過,動畫匯出才算驗收通過。
將尺寸、時間陣列與循環策略納入版本控制。不要只拿隨手錄製的動畫當作唯一基準,否則素材變動後,很難解釋測試失敗的原因。
使用 ImageIO 產生固定樣本
在獨立目錄中,將以下內容儲存為 gif-check.swift。指令碼會產生三個影格,完成寫入後重新開啟檔案;任何檢查失敗,都會以非零結束狀態退出。由於檔名固定,平行執行的工作應各自使用獨立的工作目錄。
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
let url = URL(fileURLWithPath: "timing-fixture.gif")
let delays = [0.12, 0.24, 0.36]
let colors: [[CGFloat]] = [
[1, 0, 0, 1],
[0, 1, 0, 1],
[0, 0, 1, 1]
]
func require(_ condition: Bool, _ message: String) {
if !condition {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(1)
}
}
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL, UTType.gif.identifier as CFString, delays.count, nil
) else {
fatalError("Cannot create GIF destination")
}
CGImageDestinationSetProperties(destination, [
kCGImagePropertyGIFDictionary: [
kCGImagePropertyGIFLoopCount: 0
]
] as CFDictionary)
for (index, components) in colors.enumerated() {
guard let context = CGContext(
data: nil,
width: 16,
height: 16,
bitsPerComponent: 8,
bytesPerRow: 64,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
fatalError("Cannot create bitmap context")
}
context.setFillColor(
CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(),
components: components)!
)
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
guard let image = context.makeImage() else {
fatalError("Cannot create frame")
}
CGImageDestinationAddImage(destination, image, [
kCGImagePropertyGIFDictionary: [
kCGImagePropertyGIFDelayTime: delays[index]
]
] as CFDictionary)
}
require(CGImageDestinationFinalize(destination), "GIF finalization failed")
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
fatalError("Cannot reopen GIF")
}
require(CGImageSourceGetCount(source) == delays.count, "Frame count mismatch")
let global = CGImageSourceCopyProperties(source, nil) as NSDictionary?
let globalGIF = global?[kCGImagePropertyGIFDictionary] as? NSDictionary
let loops = globalGIF?[kCGImagePropertyGIFLoopCount] as? NSNumber
require(loops?.intValue == 0, "Loop count mismatch")
var total = 0.0
for index in delays.indices {
guard let image = CGImageSourceCreateImageAtIndex(source, index, nil) else {
fatalError("Cannot decode frame")
}
require(image.width == 16 && image.height == 16, "Frame size mismatch")
let properties = CGImageSourceCopyPropertiesAtIndex(
source, index, nil
) as NSDictionary?
let gif = properties?[kCGImagePropertyGIFDictionary] as? NSDictionary
let raw = gif?[kCGImagePropertyGIFUnclampedDelayTime] as? NSNumber
let fallback = gif?[kCGImagePropertyGIFDelayTime] as? NSNumber
guard let delay = (raw ?? fallback)?.doubleValue else {
fatalError("Missing frame delay")
}
require(abs(delay - delays[index]) < 0.001, "Frame delay mismatch")
total += delay
}
require(abs(total - 0.72) < 0.001, "Total duration mismatch")
執行時,先記錄工具鏈版本,再進行驗收:
set -eu
xcrun swift --version > toolchain.txt
xcrun swift gif-check.swift
shasum -a 256 timing-fixture.gif > fixture.sha256
保留檢查碼是為了識別這次產生的檔案,並不要求不同系統版本產生完全相同的位元組。色彩表與壓縮方式可能改變;語意相同,不代表二進位內容完全一致。
接上實際匯出器,不只測試框架
上述程式先確認目前工具鏈能完成基本的編碼與解碼。整合至專案時,請將產生目標檔案的部分替換為應用程式實際匯出器的呼叫,並保留同一套讀回斷言。否則,測試通過的只是 ImageIO 範例,而不是產品本身的實作。
另外加入三組輸入:單一影格、空影格陣列,以及尺寸不一致的影格。空輸入應明確回報錯誤,不留下看似匯出成功的檔案;尺寸不一致時,應事先拒絕,或依約定統一至相同畫布,不可在未告知的情況下裁切。
目前的指令碼只檢查尺寸,尚未檢查色彩順序。正式的驗收關卡還應將每個影格繪製到格式統一的 RGBA 點陣圖,再讀取中央像素,確認順序為紅、綠、藍。不要直接比較解碼緩衝區的原始位元組:色彩空間、每列位元組跨度與像素排列方式都可能不同。
分別處理短延遲與透明影格
再新增一個獨立樣本,專門測試極短的延遲時間。讀取時優先檢查未經限幅的時間屬性,只有在該屬性不存在時,才改用一般延遲屬性,並記錄實際讀取的是哪個欄位。不要將缺少的屬性預設為零,否則損壞的樣本可能被誤認為快速動畫。
透明影格也需要獨立測試。先繪製完整背景,再疊上只占局部範圍的移動物件,檢查前一個影格是否正確清除。GIF 的透明度與調色盤能力有限,不應將半透明 PNG 的效果視為必然能達成的結果。
播放器可能自行調整極短延遲,也可能採用不同的合成實作。在目標 iOS 播放元件上連續檢查兩輪,觀察首尾銜接與殘影;不要根據遠端桌面的螢幕錄影長度反推檔案時間,因為傳輸與錄影本身都會造成偏差。
讓失敗資訊能指引修正
流水線至少應保留工具鏈版本、輸入契約、匯出檔案,以及逐影格的屬性紀錄。錯誤報告列出影格索引、預期值、實際值與屬性來源,比只寫一句「動畫不一致」更有幫助。若應用程式素材含有敏感內容,失敗報告應只使用固定樣本重現問題。
依三個層次定位問題:寫入失敗時,先檢查目標路徑與完成寫入的結果;結構檢查失敗時,檢查影格數、尺寸與時間字典;結構通過但播放異常時,再檢查循環的解讀方式、短影格時間限制與透明合成。不要一開始就清除快取或更換機器。
最終驗收應同時符合以下條件:實際匯出器成功結束、所有影格皆可解碼、量化後的時間符合契約、循環欄位正確,以及目標播放器連續播放兩輪都沒有異常。將失敗樣本保留在回歸測試集中,下次修改編碼邏輯時,優先執行這些測試。
常見問題
GIF 能精確儲存任意毫秒值的影格時間嗎?
不能。GIF 延遲以百分之一秒為單位儲存,應先約定取整規則,再依量化後的預期值驗收。播放器也可能限制過短的延遲,不能只看播放感受判斷編碼結果。
讀回檢查通過後,還需要在 iOS 上播放驗收嗎?
需要。讀回檢查驗證檔案結構與屬性;目標播放器測試則驗證循環策略、短影格處理與透明背景合成。兩種檢查不能互相取代。
為下一次建置租用一台雲端 Mac
獨享實體 Mac mini,非虛擬機器。可選擇日租、週租、月租或季租;實際配置與可用狀態請以控制台資訊為準。