macOS

macOS程式開發入門:學習用Swift開發一個圖片上傳的APP應用程式

你想要開始學習如何開發你自己的masOS應用程式,然後你可以驕傲的使用屬於你的程式在你自己的Macbook上,或是你有著滿腹的熱情開始學習程式開發在你的Mac上?那你可以來對了地方了!這兒,我將會帶你一步一步開發屬於你的Mac OS App開發,並用目前最新的語言,Swift!
macOS程式開發入門:學習用Swift開發一個圖片上傳的APP應用程式
macOS程式開發入門:學習用Swift開發一個圖片上傳的APP應用程式
In: macOS, Swift 程式語言

你想要開始學習如何開發你自己的masOS應用程式,然後你可以驕傲的使用屬於你的程式在你自己的Macbook上,或是你有著滿腹的熱情開始學習程式開發在你的Mac上?那你可以來對了地方了!這兒,我將會帶你一步一步開發屬於你的Mac OS App開發,並用目前最新的語言,Swift!

開發前需求

  • 對程式開發有興趣
  • 對Swift語言有基礎的認識
  • 需安裝Xcode9版本
  • 對開發mac APP有熱情

你將會學到什麼?

  • macOS的開發基本觀念
  • macOS App與Alamofire的整合,來呈現網路呼叫的功能
  • 可建立拖拉動作的機制行為
  • 一些Swift 3.2的語法

我們將會開發什麼?

我很肯定你興奮著了解我們將要開發出怎樣的東西,在這個教學中,我們將應用Mac的Main Application Layer,Cocoa它是負責App所呈現可視元件、網路功能與功能邏輯的外觀介面和反應使用者操作動作的行為,App主要目的就是要上傳圖片到uploads.im,並且是利用此網站所提供的API開放碼所達成的。這也將是整篇教學的最後成品。

pushimage-demo

使用者將會先看到一個首頁說明著”Drag and Drop your image here”(將你的圖片拖拉至這兒),然後使用者可將任何的jpg格式的圖片檔拖拉放到這個App內,然後App將會呈現一個讀取中的旋轉圖示來告知使用者目前正將圖片上傳至伺服器中,一但伺服器回應成功後,將會有個提醒視窗彈出的通知畫面,畫面中會有個網址可以讓你複製到剪貼簿後,你就可以貼上這個網址到瀏覽器的網址列,就可看到伺服器上的圖片。

好的!說的差不多了,我們就來開始手作吧!

建立你的macOS專案

首先,先開始我們的Xcode9,並建立你的macOS app專案,並命名為PushImage,在Application下選擇macOSCocoa App

你可以取名和我一樣的專案名稱,再選擇你想存放的檔案位置。

現在,你應設定好你的專案了,我想要藉這個機會來介紹一下我在Xcode9中最喜歡的一的新功能Refactor,如果你曾使用之前的版本,這個功能是無法在之前的Xcode版本使用的,現在請給他一個機會,先點選程式碼中ViewController,然後到Edtior > Refactor

我不希望使用初始設定的名稱ViewController,我想要改成HomeViewController,只要點選Rename,你會發現它同步修正你的filenameclassname,甚至在Storyboard viewcontroller class name。是不是很酷!

回到我們HomeViewController的類別,你應該發現了這個controller是屬於NSViewController的子類別,如果你曾了解相關iOS app開發,你會清楚我們現在用的是NS開頭的,而不是UI開頭的,這是因為我們現在使用的是Cocoa(AppKit),並非Cocoa Touch (UIKit)。我相信Apple會這麼做,主要將這兩個框架區分為不同的應用,一是屬於mobileOS,簡單而言,UIKitAppKit的瘦身版。

建立我們的首頁

你可以先從這兒下載一個雲端圖示,一但你載好後,請將它拖拉到Xcode專案內的Assets.xcassets,再將他取名為uploadCloud,並再將它移動到2x

接下來,請移至我們的Main.Storyboard來建立使用者將會互動的視圖(View),我們將使用美妙的Xcode介面建構器來設計我們的首頁。在右下方的容器中,移到第三個項目Ojbect library中,找到NSImageView,然後將它拖拉至HomeController的畫面中央。

現在,點選你剛拉好的NSImageView,並在右上角找到第四個項目,稱Attributes Inspector,將Image內選擇uploadCloud,因為我們剛剛在上一步重新命名圖像的名字!Xcode會自動登錄名稱,並直接插入,並不需要額外做些設定。這也是Xcode的一個好功能。

接著,我們需要加大一些寬度和高度,這樣它會看起來更好看點,點選在右上角的Utilities面板中的第五個項目,稱為Size Inspector。增加NSImageView的寬度與長度,變為150x150

我們也需要一些介面說明來告知使用者他們需要做些什麼,所以現在繼續在找出label,然後將它放到imageView的下方,也一樣地,我們要到Utilities功能面板中,找到Attributes Inspector,設定Title內容為Drag and Drag a .jpg image file Here。你可以看到這段字有被切斷,並沒有完整顯示,所以我們在Size Inspector中增加寬度到300,並在Text Alignment設定為Center-aligned

現在我們要把imageViewlabel一起置放在視窗的中間,首先設點選imageView,再按住鍵盤的cmd,再接著點選label,這樣代表同時選擇兩者,接著拖拉它們到視窗中間,直到你看到交叉線,這是協助你能準確將元件放至畫面中心的功能。

我們最後想要增添的倒數第二個UI元件是Indicator,搜尋到它後,並將它拖拉至視窗,請將它放置在你的Label中間。

最後的一個元件是最重要的一環,就是DragView!我們需要一個View來做為在App內可拖放的一個區域。所以剛例行的事情再做一次,搜尋NSView的元件,並拉進來,然後放大它,填滿整個視窗。然而StoryBoard的介面層概念是由下至上或可說LastInFirstOut(後進先出)的方式。所以現在這個View會覆蓋到其他View的元件。等會兒,我們將會設計這個View具有透明的背景,所以就不會擋住其他元件了。

繼續往下走,我們現已將所有的元件加到我們的Storyboard了!現在開始連結這些Outlet吧!我們會以Interface Builder來連結在ViewController的屬性變數。

目前Storyboard是我們目前的主要編輯畫面,我們可應用在Xcode的右上方找到Assistant Editor,它可以同時開啟分割視窗功能,除了Storyboard,並呈現另一個畫面來顯示我們的HomeViewController

請按住鍵盤的Ctrl,再點選ImageView並拖拉他到我們的類別宣告處,並將變數名稱取名為imageView。很好!現在imageView的outlet已經在我們的類別中連結為一個屬性變數。我們可以應用程式碼來調整它的屬性或與它互動。

換你試試看

現在,試著對viewlabelindicator做相同的動作,並將它們分別取名為dragViewstaticLabelloadingSpinner,你的程式碼應會像下列所示:

import Cocoa

class HomeViewController: NSViewController {
    @IBOutlet weak var imageView: NSImageView!
    @IBOutlet weak var staticLabel: NSTextField!
    @IBOutlet weak var loadingSpinner: NSProgressIndicator!
    @IBOutlet weak var dragView: NSView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }
}

為了讓Xcode的編輯畫面更有空間,請在Xcode介面的右上方點選第一個,切換回Show the Standard Editor,過來我們來面對下一個任務。

建立我們的Drag View

我們下一個任務是準備建一個DragView,為了可以讓我們的jpg圖檔有一個適合的”置放點”,請建立一個New File,選擇Cocoa Class,再建立一個NSView新的子類別,稱為DragView,我們將需要回到我們的Storyboard,並將dragView的類別改為DragView,如下圖所示。

回到我們的HomeViewController程式碼,並將NSView!修正為DragView!

@IBOutlet weak var dragView: DragView!

當我們再多了解NSView時,會發現它是自動地遵循我們需要使用的這個NSDraggingDestination的協定,而這個協定就是可以協助我們製作一個拖放空間的功能。

在我們開始學習如何使用拖放方法前,我們需要先register(登錄)我們的view,所以要在view 的類別更換下列的程式碼:

 required init?(coder: NSCoder) {
        super.init(coder: coder)
        registerForDraggedTypes([NSPasteboard.PasteboardType
            .fileNameType(forPathExtension: ".jpg")])
    }

這行程式碼目的是登錄一個View,是具備可以拖拉任何”.jpg”的檔式類型項目丟到App內的功能。然而我們目前做的只是登錄而已!我們的View目前還不能接受檔案。

跟著NSDraggingDestination的文件說明,我們可以總結目前我們需要達成的功能:

接著,要來加入程式碼,我將會解釋它們如何做的:

import Cocoa

class DragView: NSView {
        
    //1
    private var fileTypeIsOk = false
    private var acceptedFileExtensions = ["jpg"]
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        register(forDraggedTypes: [NSFilenamesPboardType])
    }
    
    //2
    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        fileTypeIsOk = checkExtension(drag: sender)
        return []
    }
    
    //3
    override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
        return fileTypeIsOk ? .copy : []
    }
    
    //4
    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        guard let draggedFileURL = sender.draggedFileURL else {
            return false
        }
        
        return true
    }
    
    //5
    fileprivate func checkExtension(drag: NSDraggingInfo) -> Bool {
        guard let fileExtension = drag.draggedFileURL?.pathExtension?.lowercased() else {
            return false
        }
        
        return acceptedFileExtensions.contains(fileExtension)
    }

}

//6
extension NSDraggingInfo {
    var draggedFileURL: NSURL? {
        let filenames = draggingPasteboard().propertyList(forType: NSFilenamesPboardType) as? [String]
        let path = filenames?.first
        
        return path.map(NSURL.init)
    }
}
  1. 首先,我們需要邁立一個Boolean Flag呼叫fileTypeIsOK,初始設定為false來協助我們來確認圖片的檔案格式。我們也會建立一個acceptedFileExtensions為一個字串格式的序列。
  2. draggingEntered功能是當有一個檔案要進入”Drop Area(放置區域)”時,我們將會呼叫一個函式checkExtetnsion,不過我們待會再討論這個函式,為了當檔案類型為.jpg,可讓我們的fileTypeIsOk的布林值是true,否則為false
  3. draggingUpdated函式是要實現得到圖像檔的資訊,這個案例說明如果fileTypeIsOKTrue,我們就將回傳圖像的copy, 不然它將會以[]方式回傳一個empty資料。
  4. performDragOperation函式是在當使用者一旦放開他的滑鼠後,會被呼叫,然後我們將使用這個功能來送出網址到我們的HomeViewController
  5. checkExtension是我們的”home-made(手作)”函式,當我們確認drag的物件,當檔案匯入時,得到url,並同時確認它是否和acceptedFileExtensions一致。
  6. 這兒,我們擴展我們的NSDraggingInfo,我們可以在draggingEntered看到所有的senders,我們增加一個變數叫做draggedFileURL,主要是參照我們的圖像檔的網址。

如何你現在執行這個App,你應該能夠拖拉一個.jpg檔案格式與看見一個綠色 + 符號在你的游標,但不適合其他檔案格式。太好了!現在我們知道我們的App只能接受特定正確的檔案格式。現在要開始建置view和我們的controller中間的通訊。

建立代理(Delegate)模式

Delegation Pattern 在Cocoa Programming是最常見的模板設計之一。它就像是一個控制中心會廣播說,當A做完某件事情時,所以B立即要做些什麼事。簡單來說,我們馬上就來建立:

  • 我們的控制中心(DragViewDelegate)
  • 呼叫函式didDragFileWith
  • View會參照DragViewDelegate的訂閱者,並會呼叫didDragFileWith
  • ViewController訂閱DragViewDelegate來準備做些事情,例如當View的didDragFileWith被呼叫時。

所以,回頭看我們的DragView和輸入下列的程式碼在我們的類別內,請放置在import Cocoa下方:

protocol DragViewDelegate {
    func dragView(didDragFileWith URL: NSURL)
}

我們建立了一個代理協定,主要定義代理機制下令功能,這裡唯一功能就是當dragView取得dragViewdidDragFileWith的事件呼叫。此時代理機制就會被呼叫,當然另一個訂閱者就會有所動作了!所以我們也會在訂閱者建立一個參考變數,請先在我們類別宣告下方建立如下列程式碼:

class DragView: NSView {
    
    var delegate: DragViewDelegate?

我們想要快速通知HomeViewController,有一個正確的檔案已經置放好了,所以最好的位置來放置我們的Delegate來做廣播,那就是在使用者釋放他drag event與所有的確認動作完成後,來放在performDragOperation。接下來請加入下列的程式碼:

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        guard let draggedFileURL = sender.draggedFileURL else {
            return false
        }
        
        //call the delegate
        if fileTypeIsOk {
            delegate?.dragView(didDragFileWith: draggedFileURL)
        }
        
        return true
    }

現在,再回到我們的HomeViewController,並擴展我們的類別來實現Delegate方法,請再加入下列的程式碼:

extension HomeViewController: DragViewDelegate {
    func dragView(didDragFileWith URL: NSURL) {
        print(URL.absoluteString)
    }
}

如果我們現在來執行這個App,我們會期待我們的檔案網址會出現在我們Xcode的Console上,現在來試試著!

當我們將.jpg檔案拖到App內,嗯….什麼都沒有發生,我們是不是少做了什麼?是的!我們需要我們的HomeViewController來訂閱我們View的delegate。這是開發人員常忘了加入訂閱功能,是個很常見的忽略。所以我們來修復它吧,請加入下列程式碼到viewDidLoad()

    override func viewDidLoad() {
        super.viewDidLoad()
        dragView.delegate = self
    }

當你完成後,再執行一次App,你將會發現網址的字眼有出現了!很好!能走到現在,要給自己一些掌聲鼓勵。快了快了!我們現在能將檔案放在伺服器了!

整合Alamofire

Alamofire是在Swift中一個功能很強大的網路資源庫,另一個較知名的是AFNetworking。它到現在也是支援性最佳的網路資源庫。

應用CocoaPods安裝Alamofire

CocoaPods可幫助定義我們專案的相關項目能整合在同一檔案中,並且它會建立一個workspace檔案,且自動地連接到我們的Xcode專案。如果你之前沒有任何相關CocoaPods的經驗,請先參考這篇文章教學CocoaPods 簡介 : 如何輕鬆管理 Swift / Objective-C 的類庫

我們首先需要建立一個Podfile,你可以打開終端機後,並將檔案路徑設置到你儲放專案的位置,如:({路徑位置}/PushImage)。然後執行pod init,執行成功後,會自動產生一個podfile檔,我們將它打開,並在裡面加入下列程式碼:

platform :osx, '10.10'
target 'PushImage' do
use_frameworks!

  # Pods for PushImage
     

end

然後,將它關閉,然後回到終端機執行pod install,這將會複製相關項目,你會建立一個workspace file檔內已鏈結了Alamofire framework。

cocoapods alamofire

先把目前Xcode的專案關閉,並重新開啟以Cocoapod建立的PushImage.xcworkpace檔案。

可以了!你現在已擁有與設定好網路連結資源庫 。

對uploads.im做一個網路呼叫功能

我們將使用Uploads.im所提供的api document,以它的範例來建立我們的POST

  • upload – 以POST方法來提供URL或是圖像,它會自動偵測是否上傳。

所以來匯入一個資源庫吧:

import Cocoa
import Alamofire

然後,再將下列程式碼加入dragView函式內:

extension HomeViewController: DragViewDelegate {
    func dragView(didDragFileWith URL: NSURL) {
        Alamofire.upload(multipartFormData: { (data: MultipartFormData) in
            data.append(URL as URL, withName: "upload")
        }, to: "http://uploads.im/api?format=json") { [weak self] (encodingResult) in
            switch encodingResult {
            case .success(let upload, _, _):
                upload.responseJSON { response in
                    guard
                        let dataDict = response.result.value as? NSDictionary,
                        let data = dataDict["data"] as? NSDictionary,
                        let imgUrl = data["img_url"] as? String else { return }
                    
                    print(imgUrl)
                }
            case .failure(let encodingError):
                print(encodingError)
            }
        }
    }
}

哇嗚!這段程式碼很多吧!但別害怕,還是要特別感謝Alamofire,這套資源庫提供了我們一個upload函式,來允許我們用multipartFormData取得POST檔,這是我們平常用來上載的一個網路交絡方式,我們附加了我們檔案的url,其中提供了以字串方式呈現的位置資訊。也代表的是upload這個變數伴隨著URL

http://uploads.im/api?format=json 的連接點會附在 query parameter format=json 並設定我們需要的格式,當伺服器回應upload success,我們藉由JSON的回應取得img_url,並列印出來!來試試看吧!

哇!似乎有一個狀況,就是當我們試著第一次做網路交絡時,會常發生的錯誤訊息:

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.

原因是Apple近期訂定的新規定,當所有的Apps需要遵循https協定,但因我們只是要學習並屬個人使用。所以我們嘗試著克服它。

回到你專案的Info.plist介面上,點選右鍵並選擇打開source code

然後請加入下列程式碼:

  NSAppTransportSecurity
    
        NSAllowsArbitraryLoads
        
    

這個方式可以跳過ATS,並允許我們可以先以non-https的協定做網路交握。請再執行一次App,並試著上傳其他圖像!幾秒後,你應該可以看到你上傳檔案的URL已經顯示在Xcode的console上!接著請複製這段網址,並將它貼上你的瀏覽器,你應該可以看到你的裝置可讀取到由uploads.im伺服器所提供的資料了。

發佈

我們知道我們的圖像讀取器已經快要完成,但我們仍需要一些UX(User Experienece)使用者良好體驗的思維設定來讓它更完成。

可動態讀取圈圈

先前我們討論過我們的動態讀取圈圈,所以繼續來完成這個動態讀取圈圈的功能:

  • 當App啟動時需隱藏
  • 當在上傳時,需顯示和要有些動畫效果。
  • 當上傳時完成或失敗時,也需隱藏或停止動畫。

當App啟動時需隱藏

請加入下列程式碼到viewDidLoad()

    override func viewDidLoad() {
        super.viewDidLoad()
        dragView.delegate = self
        loadingSpinner.isHidden = true
    }

上傳時的動畫效果

   func dragView(didDragFileWith URL: NSURL) {
        loadingSpinner.isHidden = false
        loadingSpinner.startAnimation(self.view)
        
        Alamofire.upload(multipartFormData: { (data: MultipartFormData) in
            data.append(URL as URL, withName: "upload")
        }, to: "http://uploads.im/api?format=json") { [weak self] (encodingResult) in
            switch encodingResult {
            case .success(let upload, _, _):
                upload.responseJSON { response in
                    guard
                        let dataDict = response.result.value as? NSDictionary,
                        let data = dataDict["data"] as? NSDictionary,
                        let imgUrl = data["img_url"] as? String else { return }
                    
                    self?.loadingSpinner.isHidden = true
                    self?.loadingSpinner.stopAnimation(self?.view)
                }
            case .failure(let encodingError):
                print(encodingError)
            }
        }
    }

請將上述的程式碼加入到dragView函式,並執行這個App,然後拉一個圖像檔,當上傳動作開始時,你應該可以看見讀取圈圈在動態動作了!

顯示/隱藏 Label

我們還是可以看見Label會擋住我們的loading spinner(讀取圈圈),所以我們要:

  • 當讀取圈圈沒作動時,顯示Label
  • 當讀取圈個作動時,隱藏Label

loadingSpinner.startAnimation(self.view)下方,加入staticLabel.isHidden = true,代表讀取圈圈執行動畫時,Labe需l要隱藏; 再來,在 self?.loadingSpinner.stopAnimation(self?.view) 的下方加入self?.staticLabel.isHidden = false ,這表示 loading spinner 讀取圈圈結束動畫後,Label就要顯示。

彈出提醒視窗

我們要加入最後一個的UI元件功能是叫Alert Box,我們將應用NSAlert,利用一些簡單的設定,來允許我們做一個友善的提醒視窗。

加入下列函式程式碼來呈現NSAlert的彈出提醒需求:

    fileprivate func showSuccessAlert(url: String) {
        let alert = NSAlert()
        alert.messageText = url
        alert.alertStyle = .informational
        alert.addButton(withTitle: "Copy to clipboard")
        let response = alert.runModal()
        if response == NSAlertFirstButtonReturn {
            NSPasteboard.general().clearContents()
            NSPasteboard.general().setString(url, forType: NSPasteboardTypeString)
        }
    }

然後,在self?.staticLabel.isHidden = false下方,再加入這行程式碼:

self?.showSuccessAlert(url: imgUrl)

現在,再來執行App,並拖拉一個圖檔,你應該可以看見一個挺好的原生彈出提醒視窗,並內含一個按鈕,功能是當你點選時,它將會自動複製到剪貼簿,所以你可以貼到你想要的地方去。

回顧

我們達成了什麼?

  • 我們學到了如何應用Cocoa勾勒出一個完整的macOS
  • 我們學到了如何使用Xcode IB設計我們的UI元素
  • 我們學到了如何建立我們想要自製的View
  • 我們學到了如何設計Delegate pattern
  • 我們學到了如何應用CocoaPods來安裝額外的資源庫
  • 我們學到了如何使用Alamofire上傳圖片
  • 我們學到了如何使用uploads.im的開源API
  • 我們學到了如何應用NSAlert

以上,一般來說要建一個macOS app,大約就是這樣子!當然還有很多部份我沒提到,再來就要靠你來摸索,並做一個有意義與實用的產品給予世界各地的人來使用吧!

總結

如何你對這個教學有任何問題,請留言在下方讓我知道。

你可以在GitHub下載完整的範例檔。

譯者簡介:Oliver Chen-工程師,喜歡美麗的事物,所以也愛上Apple,目前在iOS程式設計上仍是新手,正研讀Swift與Sketch中。生活另一個身份是兩個孩子的爸,喜歡和孩子一起玩樂高,幻想著某天自己開發的App,可以讓孩子覺得老爸好棒!。聯絡方式:電郵[email protected]

原文Beginning macOS Programming: Learn to Develop an Image Uploader App in Swift

作者
Lawrence Tan
現於 2359Media 擔任 iOS 程式設計師。他喜歡開發很棒的apps,希望自己開發的程式能改善大家的生活,令人活得更好。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。