画像圧縮 mac アプリ開発#
背景#
3 年前にプロジェクトBatchProssImageがあり、Python で書かれた画像をバッチ圧縮するツールです。最新の使用時に、使い方を忘れてしまったので、この Python で実装されたツールをシンプルな mac アプリにするアイデアが生まれました。
プロセス#
アイデアはシンプルです:当時このツールは tinypng の API を使用して圧縮していたと思うので、mac クライアントを開発し、圧縮のインターフェースを呼び出して、写真をエクスポートすれば良いのです。作業を始めました。
まず、mac クライアントの UI はどこから来るのでしょうか?以前にプロジェクトOtoolAnalyseがあり、Mach-O ファイル内の無駄なクラスとメソッドを分析するもので、LinkMapの UI を借りて実現しました。ここで考えたのは、うん、この方法を使うことができます。プロジェクトを開いてみると、OC で書かれているので、Swift で書き直すことにしました。
UI 実装#
大まかに必要な機能を考えてみます。
- ファイル || ディレクトリを選択
- エクスポート先ディレクトリを選択
- 圧縮を開始
- 圧縮進捗表示
- ああ、もう一つ、tinypng の apikey 入力
さらに考えると、エクスポート先ディレクトリを選択する必要があるのか?以前、筆者が他のアプリを使用してエクスポートを選択した際、既存の操作を中断することは言うまでもなく、選択に困る場合、毎回どこにエクスポートするかを考えるのは問題です。新しいフォルダを作成する必要があるのか、同じディレクトリを選択するとどうなるのかなど。
チェックボタンに変更し、デフォルトで同じディレクトリに直接置き換えます。なぜなら、ターゲットの使用シーンは、プロジェクトフォルダを選択し、フォルダ内の画像をスキャンして圧縮し、元のファイルを直接置き換えるからです。チェックを外すと、選択したディレクトリ内に output フォルダを作成し、圧縮された画像を output に出力します。これにより、エクスポート先ディレクトリを選択する手間が省けます。
最終的な効果図は以下の通りです:
UI の説明:
- 圧縮するファイルパス、選択したパスを表示するためのもの —— 複数のファイルを選択した場合は「複数のファイルが選択されました」と表示;単一のファイルまたはフォルダを選択した場合はパスを表示;
- パス選択ボタン、ファイルまたはディレクトリを選択;
- TinyPng の API Key、TinyPNG サイトから取得した API キーを入力するためのもの;
- 圧縮後のファイルパス同ディレクトリ置き換えボタン(このボタンの名前は少し長い [顔を隠す])、デフォルトで選択されており、選択されていると圧縮後の画像が元の画像に直接置き換えられます;選択が外れると、圧縮後の画像が選択したディレクトリと同じレベルの output フォルダに出力されます;
- インジケーター、圧縮を開始する際に圧縮中であることを示します;
- 圧縮開始ボタン、フォルダ内のサポートされている画像を取得し、圧縮を開始するインターフェースを呼び出し、圧縮後に出力します;
コード実装#
- パス選択ボタンのクリックイベントロジック、複数選択をサポートし、ディレクトリを選択し、選択が完了した後、ファイルパスの表示を更新します。
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 = "複数のファイルが選択されました"
}
}
- アップロードロジックの実装
アップロードロジックはまず tinypng のアップロード方法を知る必要があります。tinypng api referenceを開くと、HTTP
、RUBY
、PHP
、NODE.JS
、PYTHON
、JAVA
、.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/shrink
、post
形式で、Headers にキーAuthorization
、値Basic Base64EncodeStr(api:YourAPIKey)
を追加します。以下のようになります:
次に Body に切り替え、Binary を選択し、画像を追加して Send をクリックすると、インターフェースが正常に成功したことがわかります。以下のように:
アップロード圧縮インターフェースが正常に機能することが確認できたので、アプリ内で同様のロジックを実装します。
作成するダウンロードクラスTinyPNGUploadService
、Alamofire
を使用してファイルをアップロードする方法。
** 注意 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()
}
}
次に、圧縮開始ボタンをクリックしたときに、このアップロードメソッドを呼び出します。
- 圧縮対象が選択されているかを判断します。
- APIKEY が入力されているかを判断します。
- インジケーターを表示します。
- 選択されたファイルパスを繰り返し処理します。パスの場合は、パス内のファイルを繰り返し処理します。ファイルの場合は、直接判断します。
- ファイルがサポートされている圧縮形式であるかを判断します。tinyPNG は
png
、jpg
、jpeg
、webp
形式の画像圧縮をサポートしており、他のファイル形式は処理しません。 - 圧縮をサポートするファイルであれば、圧縮メソッドを呼び出し、圧縮に成功した後、進捗を最下部の ContentTextView に更新します。
- すべての画像が圧縮された後、インジケーターを非表示にします。
@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)
}
}
}
}
- ダウンロードロジックの実装
次に、ダウンロードロジックの実装を見てみましょう。まずは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 つのパラメータが必要です。
- ダウンロード後の保存先に同じファイルが存在する場合は削除します。
- HTTP ヘッダーの
Content-Type
をapplication/json
に設定する必要があります。設定しないと、最後のダウンロードがエラーになり、contentType が正しくないと表示されます。 - ダウンロードの戻り値を 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
}
完全な効果は以下の通りです:
完全なコードは GitHub に公開されています:MWImageCompressUtil、リンク:https://github.com/mokong/MWImageCompressUtil
参考#
- tinypng api reference
- (Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted" について)[https://www.cnblogs.com/xiaoqiangink/p/12197761.html]
- (Cannot Create New Directory in MacOS app)[https://stackoverflow.com/questions/50817375/cannot-create-new-directory-in-macos-app]