今是昨非

今是昨非

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

画像圧縮 mac アプリ開発

画像圧縮 mac アプリ開発#

背景#

3 年前にプロジェクトBatchProssImageがあり、Python で書かれた画像をバッチ圧縮するツールです。最新の使用時に、使い方を忘れてしまったので、この Python で実装されたツールをシンプルな mac アプリにするアイデアが生まれました。

プロセス#

アイデアはシンプルです:当時このツールは tinypng の API を使用して圧縮していたと思うので、mac クライアントを開発し、圧縮のインターフェースを呼び出して、写真をエクスポートすれば良いのです。作業を始めました。

まず、mac クライアントの UI はどこから来るのでしょうか?以前にプロジェクトOtoolAnalyseがあり、Mach-O ファイル内の無駄なクラスとメソッドを分析するもので、LinkMapの UI を借りて実現しました。ここで考えたのは、うん、この方法を使うことができます。プロジェクトを開いてみると、OC で書かれているので、Swift で書き直すことにしました。

UI 実装#

大まかに必要な機能を考えてみます。

  • ファイル || ディレクトリを選択
  • エクスポート先ディレクトリを選択
  • 圧縮を開始
  • 圧縮進捗表示
  • ああ、もう一つ、tinypng の apikey 入力

さらに考えると、エクスポート先ディレクトリを選択する必要があるのか?以前、筆者が他のアプリを使用してエクスポートを選択した際、既存の操作を中断することは言うまでもなく、選択に困る場合、毎回どこにエクスポートするかを考えるのは問題です。新しいフォルダを作成する必要があるのか、同じディレクトリを選択するとどうなるのかなど。

チェックボタンに変更し、デフォルトで同じディレクトリに直接置き換えます。なぜなら、ターゲットの使用シーンは、プロジェクトフォルダを選択し、フォルダ内の画像をスキャンして圧縮し、元のファイルを直接置き換えるからです。チェックを外すと、選択したディレクトリ内に output フォルダを作成し、圧縮された画像を output に出力します。これにより、エクスポート先ディレクトリを選択する手間が省けます。

最終的な効果図は以下の通りです:

UI 効果図

UI の説明:

  1. 圧縮するファイルパス、選択したパスを表示するためのもの —— 複数のファイルを選択した場合は「複数のファイルが選択されました」と表示;単一のファイルまたはフォルダを選択した場合はパスを表示;
  2. パス選択ボタン、ファイルまたはディレクトリを選択;
  3. TinyPng の API Key、TinyPNG サイトから取得した API キーを入力するためのもの;
  4. 圧縮後のファイルパス同ディレクトリ置き換えボタン(このボタンの名前は少し長い [顔を隠す])、デフォルトで選択されており、選択されていると圧縮後の画像が元の画像に直接置き換えられます;選択が外れると、圧縮後の画像が選択したディレクトリと同じレベルの output フォルダに出力されます;
  5. インジケーター、圧縮を開始する際に圧縮中であることを示します;
  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()
         }
     }
 }

     /// パス表示ラベルの文字を更新
 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 エンコードして得られた文字列 xxx の前にBasic xxxを付け、最後に HTTP ヘッダーのAuthorizationに入れます;最後に画像データを body に入れてアップロードします。

手を動かす前に、このインターフェースが正常に機能するかどうかを確認します。Postman を開き、新しいインターフェースを作成し、インターフェースリンクをhttps://api.tinify.com/shrinkpost形式で、Headers にキーAuthorization、値Basic Base64EncodeStr(api:YourAPIKey)を追加します。以下のようになります:

Postman アップロード検証 1

次に Body に切り替え、Binary を選択し、画像を追加して Send をクリックすると、インターフェースが正常に成功したことがわかります。以下のように:

Postman アップロード検証 2

アップロード圧縮インターフェースが正常に機能することが確認できたので、アプリ内で同様のロジックを実装します。

作成するダウンロードクラスTinyPNGUploadServiceAlamofireを使用してファイルをアップロードする方法。

** 注意 1:** アップロード時に、Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"というエラーが出続け、調査した結果、mac アプリのネットワークリクエストには Target——>Signing && Capabilities の App Sandbox の Network オプションでOutgoing Connections(Client)をチェックする必要があることがわかりました。

注意 2: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. インジケーターを表示します。
  4. 選択されたファイルパスを繰り返し処理します。パスの場合は、パス内のファイルを繰り返し処理します。ファイルの場合は、直接判断します。
  5. ファイルがサポートされている圧縮形式であるかを判断します。tinyPNG はpngjpgjpegwebp形式の画像圧縮をサポートしており、他のファイル形式は処理しません。
  6. 圧縮をサポートするファイルであれば、圧縮メソッドを呼び出し、圧縮に成功した後、進捗を最下部の ContentTextView に更新します。
  7. すべての画像が圧縮された後、インジケーターを非表示にします。

@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: "OK")
     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 は圧縮後の画像データが含まれています。サイズ、タイプ、幅、高さ、圧縮比、画像リンクが含まれています。圧縮後に返されるのは画像リンクなので、残りの部分は圧縮後の画像をダウンロードして指定のフォルダに保存することです。

戻り値のデータを使用するために、返されたデータを解析するためのモデルクラスを宣言します。以下の通りです:


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内のメソッドを修正し、モデルクラスに解析してコールバックを返すようにします。以下の通りです:


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 をコピーしてプライベートブラウザに直接貼り付けると開けます)。しかし、念のため、例に従ってヘッダーに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 の 3 つのパラメータが必要です。

  1. ダウンロード後の保存先に同じファイルが存在する場合は削除します。
  2. HTTP ヘッダーのContent-Typeapplication/jsonに設定する必要があります。設定しないと、最後のダウンロードがエラーになり、contentType が正しくないと表示されます。
  3. ダウンロードの戻り値を responseString で印刷することはできません。なぜなら、string は png データであり、長い文字列を印刷すると理解できない文字が表示されるからです。

最終的なコードは以下の通りです:


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?()
                }
            }
    }
}

次に、ダウンロードを呼び出すタイミングを考えます。アップロードが完了した後、ダウンロードリンクを取得できる必要があります。出力が完了する前に、まずローカルにダウンロードする必要があります。

ダウンロードファイルのディレクトリは、チェックボタンが選択されているかどうかによって異なります。選択されている場合は置き換え、現在のファイル URL を返します。選択されていない場合は、同じディレクトリに output ディレクトリを追加し、output に保存します。

コードは以下の通りです:

    fileprivate var isSamePath: Bool = true // デフォルトは同じパス

    /// チェックボタンの選択状態
    @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”は、アクセス権がないため削除できませんでした。")。ローカルファイルへの書き込み権限がないため、同様に Target——>Signing && Capabilities の App Sandbox の File Access オプションでUser Selected Fileの権限をRead/Writeに変更する必要があることがわかりました。以下のように:

読み書き権限を開く示意図

再度試みると、同じファイルの置き換えが成功しました。

次に、output ディレクトリに保存する場合を試みると、再びエラーが発生しましたdownloadedFileMoveFailed(error: Error Domain=NSCocoaErrorDomain Code=513 "「output」を「CompressTestFolder」フォルダ内に保存する権限がありません。")。同様に権限がないため、長い間詰まっていました。この問題を解決するために多くの資料を調べた結果、以下の回答を見つけました(Cannot Create New Directory in MacOS app)[https://stackoverflow.com/questions/50817375/cannot-create-new-directory-in-macos-app]。Mac アプリは Sandbox モードでは自動的にディレクトリを作成できず、以下の解決策が示されています:

使用ケースに応じて

  • サンドボックスモードを無効にする —— セキュリティモードを無効にする
  • ユーザーにフォルダを選択させるために「開く」ダイアログを開く(その後、書き込むことができます)—— ユーザー自身に書き込みディレクトリを指定させる、つまりパス選択ボタンを提供し、ファイルまたはディレクトリを選択させる;
  • 他の保護されたユーザーフォルダ(ダウンロードなど)で読み書きを有効にするか —— 別のディレクトリ、例えばダウンロードフォルダに変更し、読み書き権限を有効にする
  • ソフトリンクフォルダを使用せずに、ホームディレクトリに直接 TestDir を作成する —— 主ディレクトリに直接フォルダを作成する

示された解決策に従い、最も簡単な方法として、サンドボックスモードを削除し、Target——>Signing && Capabilities の App Sandbox モジュールを削除しました。再度デバッグを行うと、フォルダの作成が成功しました。

最適化として、上記のステップが完了した後、全体の効果はすでに実現可能ですが、使用者にとってはあまり直感的ではありません。一方で、アップロードとダウンロードの 2 つのステップが含まれており、ユーザーは各ステップにフィードバックを求めるかもしれません。もう一方では、最終的な圧縮効果が直感的に感じられず、どのステップが完了したのかがわかりません。すでに圧縮成功後に元の画像と圧縮後の画像のサイズと圧縮比が返されることがわかっているので、さらに最適化できます。

  • アップロード圧縮後、圧縮が完了したことを表示し、サイズが xx% 圧縮されたことを示します。
  • ダウンロードしてフォルダに保存した後、書き込みが完了したことを表示し、最終的なサイズは約 xxKb であることを示します。
  • 各ステップの元の画像サイズと圧縮後のサイズの差を保存し、すべての画像が圧縮された後、全体で 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

参考#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。