画像圧縮 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 キー、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 をクリックすると、インターフェースが成功を返しました。以下のように:
アップロード圧縮インターフェースが正常に動作することが確認できたので、アプリ内で同様のロジックを実装します。
** 注意 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 "“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
}
完全な効果は以下の通りです:
完全なコードは 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]
- (MacOS アプリで新しいディレクトリを作成できない)[https://stackoverflow.com/questions/50817375/cannot-create-new-directory-in-macos-app]