今是昨非

今是昨非

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

画像圧縮 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 キー、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

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

** 注意 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 "“CompressTestFolder”フォルダ内に“output”ファイルを保存する権限がありません。"、同様に権限がないため、長い間詰まっていました。ディレクトリを作成できなかったので、いろいろな資料を調べた結果、以下の回答 (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

参考#

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