今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

圖片壓縮 mac 應用開發

圖片壓縮 mac 應用開發#

背景#

3 年前有個項目BatchProssImage,使用 Python 寫的批量壓縮圖片的,最新再次使用時,發現忘記了怎麼使用,所以就有了把這個 Python 實現的工具,做成一個簡單的 mac app 的想法。

過程#

想法很簡單:印象中當時這個工具是使用 tinypng 的 api 壓縮的,所以開發一個 mac 客戶端,調用壓縮的接口,導出照片就可以。開始動工。

首先 mac 客戶端的 UI 從哪裡來?之前有個項目OtoolAnalyse—— 分析 Mach-O 文件中無用的類和方法,是借LinkMapUI 來實現的。這裡想了想,嗯,還可以用這個方法。打開項目一看,OC 的,還是用 Swift 寫一遍吧。

UI 實現#

想一下大致需要哪些功能,

  • 選擇文件 || 目錄
  • 選擇導出目錄
  • 開始壓縮
  • 壓縮進度顯示
  • 噢噢,還有一個,tinypng apikey 輸入

再考慮一下,選擇導出目錄是否必要?之前筆者自己使用其他 APP 選擇導出時,打斷先有的操作且不說,對於選擇困難來說,每次考慮要導出到哪裡都是一個問題,要不要新建一個文件夾,還選擇同目錄會是什麼效果等等。

改為 check 按鈕,默認同目錄直接替換,因為目標的使用場景是,選擇項目文件夾,掃描文件夾中的圖片,壓縮,然後直接替換原文件;取消 check 選中時,則在選中目錄下創建 output 文件夾,把壓縮後的圖片輸出到 output 中,這樣就避免了選擇導出目錄的麻煩。

所以最終效果圖如下:

UI 效果圖

UI 描述:

  1. 要壓縮文件路徑,用於顯示選擇路徑的路徑 —— 如果選擇多個文件,則顯示已選擇多個文件;如果選擇單個文件或文件夾,則顯示路徑;
  2. 選擇路徑按鈕,選擇文件或者目錄;
  3. TinyPng 的 API Key,用於輸入 TinyPNG 網站獲取到的 API key,接口調用使用。
  4. 壓縮後文件路徑同目錄替換按鈕,(這個名字按鈕起的有點長 [捂臉]),默認選中,選中時壓縮後的圖片直接替換原圖片;取消選中時,壓縮後的圖片輸出到選擇目錄同級的 output 文件夾下;
  5. indicator,用於開始壓縮時表示正在壓縮;
  6. 開始壓縮按鈕,獲取文件夾下的支持壓縮的圖片,調用開始壓縮的接口壓縮,壓縮後輸出;

代碼實現#

  1. 選擇路徑按鈕點擊事件邏輯,支持多選,支持選擇目錄,選擇完成後,更新文件路徑的顯示
    
 fileprivate var fileUrls: [URL]? // 選擇的文件路徑

 @IBAction func choosePathAction(_ sender: Any) {
     let panel = NSOpenPanel()
     panel.allowsMultipleSelection = true // 支持多選文件
     panel.canChooseDirectories = true // 可以選擇目錄
     panel.canChooseFiles = true // 可以選擇文件
     panel.begin { response in
         if response == .OK {
             self.fileUrls = panel.urls
             self._privateUpdateFilePathLabelDisplay()
         }
     }
 }

     /// 更新路徑顯示 label 的文字
 fileprivate func _privateUpdateFilePathLabelDisplay() {
     guard let fileUrls = fileUrls else {
         // 默認展示
         filePath.stringValue = "要壓縮文件路徑"
         return
     }

     if fileUrls.count == 1 {
         // 說明選擇的是單個文件 || 文件夾
         filePath.stringValue = "已選擇:" + (fileUrls.first?.absoluteString ?? "")
     }
     else {
         filePath.stringValue = "已選擇多個文件"
     }
 }

  1. 上傳邏輯實現

上傳邏輯首先需要知道 tinypng 上傳的方式是什麼樣的,打開tinypng api reference,可以看到支持HTTPRUBYPHPNODE.JSPYTHONJAVA.NET方式的上傳,其中除了HTTP外,其他的都是提供已經編譯好的庫,所以,在這裡只能用HTTP方式來上傳。

先思考一下,之前做的項目的圖片上傳,都需要哪些字段,然後瀏覽文檔,對比找到這些字段,然後驗證。

確認了,上傳的域名是https://api.tinify.com/shrink;上傳需要認證,認證的方式是HTTP Basic Auth,格式是獲取到的 APIKEY,加上 api:APIKEY,再通過 base64 Encode 得到一個字符串 xxx,再在字符串前拼接Basic xxx,最後放到 HTTPHeader 的 Authorization中;最後上傳需要圖片 data 數據,放在 body 中。

在動手前,先驗證一下,這個接口是不是這樣工作的,能不能正常使用,打開 Postman,新建一個接口,接口鏈接為https://api.tinify.com/shrinkpost格式,在 Headers 中 添加 key 為Authorization, value 為Basic Base64EncodeStr(api:YourAPIKey),如下:

Postman 上傳驗證 1

然後切到 Body,選擇 Binary,添加一張圖片,點擊 Send,可以看到接口返回成功了,如下:

Postman 上傳驗證 2

說明上傳壓縮接口可以正常工作,然後在來到 APP 中實現類似的邏輯:

創建下載類TinyPNGUploadService,使用 Alamofire上傳文件方法。

** 注意一:** 上傳時,一直報錯,Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted",排查後發現,需要 mac app 網絡請求需在 Target——>Signing && Capabilities 中,勾選 App Sandbox 下的 Network 選項中的Outgoing Connections(Client)

** 注意二:** 不能使用AF.upload(multipartFormData..的方法,否則會報錯Status Code: 415,這裡調試了好久。。。


import Foundation
import AppKit
import Alamofire

let kTinyPNGCompressHost: String = "https://api.tinify.com/shrink"

public struct TinyPNGUploadService {
    /// 上傳圖片
    /// - Parameter url: 待上傳圖片的 url
    static func uploadFile(with url: URL, apiKey: String, responseCallback: ((UploadResponseItem?) -> Void)?) {
        let needBase64Str = "api:" + apiKey
        let authStr = "Basic " + needBase64Str.toBase64()
        let header: HTTPHeaders = [
            "Authorization": authStr,
        ]
        
        AF.upload(url, to: kTinyPNGCompressHost, method: .post, headers: header)
        .responseString(completionHandler: { response in
            print(response)
            // Fixed-Me:
            responseCallback?(nil) 
        })
    }
}

extension String {
    func fromBase64() -> String? {
        guard let data = Data(base64Encoded: self) else {
            return nil
        }

        return String(data: data, encoding: .utf8)
    }

    func toBase64() -> String {
        return Data(self.utf8).base64EncodedString()
    }
}

然後在點擊開始壓縮按鈕時,調用封裝的這個上傳方法。

  1. 上傳前判斷是否選擇待壓縮對象,
  2. 判斷是否輸入 APIKEY,
  3. 展示 indicator
  4. 遍歷選擇的文件路徑,如果是路徑,則遍歷路徑下的文件;如果是文件,則直接判斷
  5. 判斷文件是否是支持壓縮的格式,tinyPNG 支持pngjpgjpegwebp格式的圖片壓縮,其他文件格式則不做處理
  6. 如果是支持壓縮的文件,則調用壓縮方法,壓縮成功後,更新進度到最底部的 ContentTextView 中
  7. 所有圖片都壓縮後,隱藏 indicator

@IBAction func compressAction(_ sender: Any) {
     guard let urls = fileUrls, urls.count > 0 else {
         _privateShowAlert(with: "請選擇要壓縮的路徑")
         return
     }
     
     let apiKey = keyTF.stringValue
     guard apiKey.count > 0 else {
         _privateShowAlert(with: "請輸入 TinyPNG 的 APIKey")
         return
     }
     
     _privateIncatorAnimate(true)
     
     let group = DispatchGroup()
     
     let fileManager = FileManager.default
     for url in urls {
         let urlStr = url.absoluteString
         if urlStr.hasSuffix("/") {
             // "/"結尾說明是目錄
             let dirEnumator = fileManager.enumerator(at: url, includingPropertiesForKeys: nil)
             while let subFileUrl = dirEnumator?.nextObject() as? URL {
                 print(subFileUrl)
                 if _privateIsSupportImageType(subFileUrl.pathExtension) {
                     group.enter()
                     _privateCompressImage(with: subFileUrl, apiKey: apiKey) {
                         
                         group.leave()
                     }
                 }
             }
         }
         else if _privateIsSupportImageType(url.pathExtension) {
             print(url)
             group.enter()
             _privateCompressImage(with: url, apiKey: apiKey) {
                 
                 group.leave()
             }
         }
     }
     
     group.notify(queue: DispatchQueue.main) {
         self._privateIncatorAnimate(false)
     }
 }

 fileprivate func _privateIncatorAnimate(_ isShow: Bool) {
     indicatorView.isHidden = !isShow
     if isShow {
         indicatorView.startAnimation(self)
     }
     else {
         indicatorView.stopAnimation(self)
     }
 }
 
 /// 調用 API 壓縮圖片
 fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
     TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
            let str = url.absoluteString + "壓縮已完成\n"
            self.resultOutput += str
            self.contentTextView.string = self.resultOutput

         callback?()
     })
 }
 
 /// 判斷是否是支持壓縮的圖片格式
 fileprivate func _privateIsSupportImageType(_ typeStr: String) -> Bool {
     let supportLists: [String] = [
         "jpeg",
         "JPEG",
         "jpg",
         "JPG",
         "png",
         "PNG",
         "webp",
         "WEBP",
     ]
     
     if supportLists.contains(typeStr) {
         return true
     }
     else {
         return false
     }
 }

     /// 彈窗
 fileprivate func _privateShowAlert(with str: String) {
     let alert = NSAlert()
     alert.messageText = str
     alert.addButton(withTitle: "確定")
     alert.beginSheetModal(for: NSApplication.shared.keyWindow!)
 }

運行後選擇一張圖片,點擊開始壓縮,最後效果如下:

上傳壓縮演示效果

嗯哼,已經完成了 30%,上傳壓縮的部分完成了,但是來看下上傳後接口返回的數據


{
    "input": {
        "size": 2129441,
        "type": "image/png"
    },
    "output": {
        "size": 185115,
        "type": "image/png",
        "width": 750,
        "height": 1334,
        "ratio": 0.0869,
        "url": "https://api.tinify.com/output/59dt7ar44cvau1tmnhpfhp42f35bdpd7"
    }
}

壓縮後返回的數據中,input 是之前的圖片大小和類型,output 是壓縮後的圖片數據,包含大小、類型、寬高、壓縮比、圖片鏈接。可以看到壓縮後返回的是一個圖片鏈接,所以剩下的部分,就是把壓縮後的圖片下載下來,保存到指定文件夾。

由於要用到返回的數據,所以聲明一個 model 類用來解析返回的數據,如下:


import Foundation

struct UploadResponseItem: Codable {
    var input: UploadReponseInputItem
    var output: UploadResponseOutputItem
}

struct UploadReponseInputItem: Codable {
    var size: CLongLong
    var type: String
}

struct UploadResponseOutputItem: Codable {
    var size: CLongLong
    var type: String
    var width: CLongLong
    var height: CLongLong
    var ratio: Double
    var url: String
}

然後修改上傳類TinyPNGUploadService中的方法,改為解析成 model 類,回調 model 類,如下:


public struct TinyPNGUploadService {
    /// 上傳圖片
    /// - Parameter url: 待上傳圖片的 url
    static func uploadFile(with url: URL, apiKey: String, responseCallback: ((UploadResponseItem?) -> Void)?) {
        let needBase64Str = "api:" + apiKey
        let authStr = "Basic " + needBase64Str.toBase64()
        let header: HTTPHeaders = [
            "Authorization": authStr,
        ]
        
        AF.upload(url, to: kTinyPNGCompressHost, method: .post, headers: header)
//        .responseString(completionHandler: { response in
//            print(response)
//            responseCallback?(nil)
//        })
        .responseDecodable(of: UploadResponseItem.self) { response in
            switch response.result {
            case .success(let item):
                responseCallback?(item)
            case .failure(let error):
                print(error)
                responseCallback?(nil)
            }
        }
    }
}

  1. 下載邏輯的實現

然後來看下下載邏輯的實現,首先還是去tinypng api reference 中,看到Example download request中,示例中下載還寫了Authorization(雖然實際上不需要,因為直接複製 URL,到隱私瀏覽器,可以直接打開),但是保險起見,還是按照示例的,在 header 中添加Authorization

由於都需要 Authorization,所以把生成Authorization的方法封裝,放到 String 的 Extension 中,又因為都需要上傳和下載都需要調用這個方法,所以把 Extension 單獨抽成一個類String_Extensions,如下:


import Foundation

public extension String {
    func tinyPNGAuthFormatStr() -> String {
        let needBase64Str = "api:" + self
        let authStr = "Basic " + needBase64Str.toBase64()
        return authStr
    }
    
    func fromBase64() -> String? {
        guard let data = Data(base64Encoded: self) else {
            return nil
        }

        return String(data: data, encoding: .utf8)
    }

    func toBase64() -> String {
        return Data(self.utf8).base64EncodedString()
    }

}

然後把上傳類中,生成 authStr 的地方修改成如下:

        
        let authStr = apiKey.tinyPNGAuthFormatStr()

再來創建下載類,TinyPNGDownloadService,下載方法需要三個參數,要下載圖片的 URL,下載後保存的地址,以及 tiny png 的 apikey,

  1. 注意下載後保存地址如果存在同文件則移除。
  2. 注意需要設置 HTTPHeader 中Content-Typeapplication/json,如果不設置,最後下載會錯誤,提示 contentType 不對。
  3. 注意下載返回不能用 responseString 打印,因為 string 是 pngdata,打印一長串看不懂的字符。

最終代碼如下:


import Foundation
import AppKit
import Alamofire

public struct TinyPNGDownloadService {
    
    /// 下載圖片
    /// - Parameters:
    ///   - url: 要下載的圖片鏈接
    ///   - destinationURL: 下載後圖片的保存位置
    ///   - apiKey: tinypng 的 APIKey
    ///   - responseCallback: 回調結果
    static func downloadFile(with url: URL, to destinationURL: URL, apiKey: String, responseCallback: (() -> Void)?) {
        let authStr = apiKey.tinyPNGAuthFormatStr()
        let header: HTTPHeaders = [
            "Authorization": authStr,
            "Content-type": "application/json"
        ]
        
        let destination: DownloadRequest.Destination = { _, _ in
         return (destinationURL, [.createIntermediateDirectories, .removePreviousFile])
        }

        AF.download(url, method: .post, headers: header, to: destination)
            .response { response in
                switch response.result {
                case .success(_):
                    responseCallback?()
                case .failure(let error):
                    print(error)
                    responseCallback?()
                }
            }
    }
}

然後來考慮調用下載的時機;需要在上傳完成後可以獲取到要下載的鏈接,輸出顯示已完成前,應該先下載到本地。

下載文件目錄,根據 check 按鈕是否選中,如果選中則是替換,直接返回當前文件 url 即可;如果未選中,則按照同目錄添加 output 目錄,保存在 output 下。

代碼如下:

    fileprivate var isSamePath: Bool = true // 默認是相同路徑

    /// check 按鈕的選中與否
    @IBAction func checkBtnAction(_ sender: NSButton) {
        print(sender.state)
        isSamePath = (sender.state == .on)
    }

/// 調用 API 壓縮圖片
    fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
        TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
            if let tempUrlStr = uploadResItem?.output.url,
               let tempUrl = URL(string: tempUrlStr) {
                let destinationUrl = self._privateGetDownloadDestinationPath(from: url)
                TinyPNGDownloadService.downloadFile(with: tempUrl,
                                                    to: destinationUrl,
                                                    apiKey: apiKey) {
                    self._privateUpdateContentOutDisplay(with: url)
                    callback?()
                }
            }
            else {
                self._privateUpdateContentOutDisplay(with: url)
                callback?()
            }
        })
    }
    
    /// 更新輸出顯示
    fileprivate func _privateUpdateContentOutDisplay(with url: URL) {
        let str = url.absoluteString + "壓縮已完成\n"
        self.resultOutput += str
        self.contentTextView.string = self.resultOutput
    }
    
    /// 獲取下載文件保存的目錄
    fileprivate func _privateGetDownloadDestinationPath(from url: URL) -> URL {
        if isSamePath {
            // 直接替換原文件
            return url
        }
        else {
            // 在文件目錄中新建 output 文件夾,放入 output 下
            let fileName = url.lastPathComponent
            let subFolderPath = String(format: "output/%@", fileName)
            let destinationUrl = URL(fileURLWithPath: subFolderPath, relativeTo: url)
            return destinationUrl
        }
    }

運行調試,首先是同文件替換的情況,發現下載成功,但是保存報錯downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "“IMG_2049.PNG” couldn’t be removed because you don’t have permission to access it.",沒有權限寫入本地文件,同樣還是需要修改 Target——>Signing && Capabilities 中,修改 App Sandbox 下的 File Access 選項中的User Selected File,權限改為Read/Write,如下:

打開讀寫權限示意圖

再次嘗試,發現同文件替換可以成功了。

再來嘗試,保存到 output 目錄的情況。發現又報錯downloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "You don’t have permission to save the file “output” in the folder “CompressTestFolder”.",同樣是沒有權限,這個卡住了好久,一直不能創建文件夾,查了很久資料發現這個答案 (Cannot Create New Directory in MacOS app)[https://stackoverflow.com/questions/50817375/cannot-create-new-directory-in-macos-app],Mac app 在 Sandbox 模式下,不能自動創建目錄,給出的解決辦法有下面這些:

Depending on your use case you can

  • disable the sandbox mode—— 禁用安全模式
  • let the user pick a folder by opening an "Open" dialog (then you can write to this)—— 讓用戶自己指定寫入目錄,即提供選擇路徑按鈕,選擇文件或者目錄;
  • enable read/write in some other protected user folder (like Downloads, etc.) or—— 換個目錄,比如 Downloads 文件夾,開啟讀寫權限
  • create the TestDir directly in your home directory without using any soft linked folder—— 直接在主目錄中創建文件夾

按照給出的解決辦法,採用最簡單的,刪除了 Sandbox 模式,Target——>Signing && Capabilities 中,刪除 App Sandbox 模塊,再次調試,即可創建文件夾成功。

優化,上面步驟完成後,整體的效果已經可以實現了,但是對於使用者來說,不太直觀。一方面:中間包含了兩步,上傳和下載,用戶可能更偏向於每一步都有反饋;另一方面,對於最終壓縮的效果,沒有一個直觀的感受,只看到了某一步完成,但是壓縮的程度沒有顯現出來。已經知道了上傳成功後會返回原始圖片和壓縮後圖片的大小和壓縮比,所以可以進一步優化一下。

  • 上傳壓縮後,顯示壓縮已完成,壓縮了 xx% 的大小
  • 下載保存到文件夾後,顯示寫入已完成,最終大小約為
  • 保存每一步的原始圖片大小,和壓縮後大小的差值,最後所有都壓縮完成後,總體顯示相比之前壓縮掉了 xxKb
    fileprivate var totalCompressSize: CLongLong = 0 // 共計壓縮掉的大小

    @IBAction func compressAction(_ sender: Any) {
        
        ...

        group.notify(queue: DispatchQueue.main) {
            self.resultOutput += String(format: "\n 總計:相比之前共壓縮掉了%ldKb", self.totalCompressSize/1024)
            self.contentTextView.string = self.resultOutput
            self._privateIncatorAnimate(false)
        }
    }


  /// 調用 API 壓縮圖片
    fileprivate func _privateCompressImage(with url: URL, apiKey: String, callback: (() -> Void)?) {
        TinyPNGUploadService.uploadFile(with: url, apiKey: apiKey, responseCallback: { uploadResItem in
            let compressSize = (uploadResItem?.input.size ?? 0) - (uploadResItem?.output.size ?? 0)
            self.totalCompressSize += compressSize
            
            self._privateUpdateContentOutDisplay(with: url, isCompressCompleted: false, item: uploadResItem?.output)
            if let tempUrlStr = uploadResItem?.output.url,
               let tempUrl = URL(string: tempUrlStr) {
                let destinationUrl = self._privateGetDownloadDestinationPath(from: url)
                TinyPNGDownloadService.downloadFile(with: tempUrl,
                                                    to: destinationUrl,
                                                    apiKey: apiKey) {
                    self._privateUpdateContentOutDisplay(with: url, isCompressCompleted: true, item: uploadResItem?.output)
                    callback?()
                }
            }
            else {
                callback?()
            }
        })
    }
    
    /// 更新輸出顯示
    fileprivate func _privateUpdateContentOutDisplay(with url: URL, isCompressCompleted: Bool, item: UploadResponseOutputItem?) {
        var suffixStr: String = ""
        if let outputItem = item {
            let ratio = 1.0 - outputItem.ratio
            suffixStr = "壓縮已完成,壓縮了: " + String(format: "%.0f", ratio*100) + "%的大小\n"
            if isCompressCompleted {
                suffixStr = String(format: "寫入已完成,最終大小約為:%.ldKb \n", outputItem.size/1024)
            }
        }
        else {
            suffixStr = "壓縮已完成\n"
            if isCompressCompleted {
                suffixStr = "寫入已完成\n"
            }
        }

        let str = url.absoluteString + suffixStr
        self.resultOutput += str
        self.contentTextView.string = self.resultOutput
    }

完整效果如下:

PageCallback.gif

完整代碼已放到 Github:MWImageCompressUtil,鏈接:https://github.com/mokong/MWImageCompressUtil

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。