Mac OS X Application

Mac Application

1. Giới thiệu

Gần đây, chính sách mới của Apple đã gộp iOS và Mac developer program vào làm một, chúng ta chỉ phải trả 99$/năm cho cả 2 program, việc này khuyến khích các lập trình viên iOS viết thêm phiên bản Mac cho các app iOS của họ, hay viết các app Mac mới hoàn toàn, giúp gia tăng số lượng ứng dụng cho Mac, nhằm cạnh tranh với Windows Market.

Có một thuận lợi lớn cho các lập trình viên iOS khi muốn viết app cho Mac đó là việc tạo một app cho Mac khá tương đồng với iOS, bạn vẫn dùng có thể dùng Objective-C hay Swift để code và phần lớn các thư viện đều có phiên bản cho Mac. Công việc khó khăn nhất với chúng ta đấy là phần thư viện UI của Mac tương đối khác với iOS.

Trong bài viết này tôi sẽ hướng dẫn các bạn viết một ứng dụng Mac đơn giản dùng để quản lý sản phẩm.

2. Ứng dụng MGMacProductDemo

2.1. Tạo project

Để bắt đầu chúng ta tạo một project mới theo template OS X > Application > Cocoa Application

C0687AEC6BDD93C01F8C3EC2FFDF8241.png

Ta điền các thông tin của project tương tự dưới đây, ngôn ngữ Swift và sử dụng Storyboards

9B49980658AB27AF2B1480FCCE15D522.png

Sau đó chúng ta có thể chạy thử ứng dụng qua menu Product > Run (Command + R), ta sẽ thấy 1 cửa sổ chương trình trắng tinh như hình dưới.

111200FC6477F74CD90FFB1C5F259454.png

2.2. Core Data

Ứng dụng của chúng ta sẽ sử dụng database để lưu thông tin sản phẩm, để thêm database, ta chọn New File > OS X > Core Data > Data Model

Sau đó ta thêm ProductEntity như sau:

A3FA5421899F930D34D121164631D9DE.png

Tạo NSManagedObject subclass cho model vừa tạo bằng cách chuột phải vào file Model chọn New File > OS X > Core Data > NSManagedObject subclass

FAF558FFF82079AC80B67106FBD59A63.png

Ta cần include file ProductEntity.h vào file MGMacProductDemo-Bridging-Header.h (file này sẽ được Xcode thêm vào khi ta thêm file Objective-C vào dự án)

#import "ProductEntity.h"

MagicalRecord

Ta sẽ dùng thư viện MagicalRecord để tương tác với Code Data. Import vào dự án thông qua CocoaPod

pod 'MagicalRecord', '~> 2.3'

Thêm dòng import vào file MGMacProductDemo-Bridging-Header.h

#import "MagicalRecord.h"

Để cấu hình MagicalRecord chúng ta cần 2 dòng lệnh, 1 dòng dùng để khởi tạo và 1 dòng dùng để dọn dẹp sau khi đóng ứng dụng. Dòng khởi tạo chúng ta sẽ viết tại hàm windowDidLoad() của MainWindowController sẽ trình bày ở dưới, dòng dọn dẹp chúng ta viết ở applicationWillTerminate(_:) của AppDelegate

class AppDelegate: NSObject, NSApplicationDelegate
    func applicationDidFinishLaunching(aNotification: NSNotification) {
        // Insert code here to initialize your application
    }

    func applicationShouldTerminateAfterLastWindowClosed(sender: NSApplication) -> Bool {
        return true
    }

    func applicationWillTerminate(aNotification: NSNotification) {
        MagicalRecord.cleanUp()
    }
}

2.3. Repository, Service

Để không sử dụng product model trực tiếp, ta sẽ dùng class Product và map với ProductEntity thông qua class Mapper.

Product

class Product {
    var id = ""
    var creationDate = NSDate()
    var modificationDate = NSDate()
    var name = ""
    var price = 0.0
}

Mapper

class Mapper {
    var id = NSUUID().UUIDString
    var creationDate = NSDate()
    var modificationDate = NSDate()
    var name = ""
    var price = 0.0

    class func mapProduct(product: Product, fromProductEntity productEntity: ProductEntity) {
        product.id = productEntity.id ?? ""
        product.creationDate = productEntity.creationDate ?? NSDate()
        product.modificationDate = productEntity.modificationDate ?? NSDate()
        product.name = productEntity.name ?? ""
        product.price = productEntity.price?.doubleValue ?? 0
    }

    class func mapProductEntity(productEntity: ProductEntity, fromProduct product: Product) {
        productEntity.id = product.id
        productEntity.creationDate = product.creationDate
        productEntity.modificationDate = product.modificationDate
        productEntity.name = product.name
        productEntity.price = product.price
    }

    class func productFromProductEntity(productEntity: ProductEntity) -> Product {
        let product = Product()
        mapProduct(product, fromProductEntity: productEntity)
        return product
    }
}

ProductRepository

ProductRepository dùng để tương tác với database, thực hiện các chức năng lấy danh sách product, thêm, sửa, xóa product.

class ProductRepository: NSObject {

    func getAll() -> [Product] {
        var products = [Product]()

        if let productEntities = ProductEntity.MR_findAllSortedBy("modificationDate", ascending: false) as? [ProductEntity] {
            for entity in productEntities {
                products.append(Mapper.productFromProductEntity(entity))
            }
        }

        return products
    }

    func addProduct(product: Product) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            if let entity = ProductEntity.MR_createEntityInContext(context) {
                Mapper.mapProductEntity(entity, fromProduct: product)
            }
        }
    }

    func updateProduct(product: Product) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            let predicate = NSPredicate(format: "id = '\(product.id)'")
            if let entity = ProductEntity.MR_findFirstWithPredicate(predicate, inContext: context) {
                entity.modificationDate = NSDate()
                Mapper.mapProductEntity(entity, fromProduct: product)
            }
        }
    }

    func deleteProduct(product: Product) {
        MagicalRecord.saveWithBlockAndWait { (context) -> Void in
            let predicate = NSPredicate(format: "id = '\(product.id)'")
            ProductEntity.MR_deleteAllMatchingPredicate(predicate, inContext: context)
        }
    }
}

ProductService

Để tăng tính lỏng cho chương trình, chúng ta sẽ dùng ProductService implement ProductServiceProtocol

protocol ProductServiceProtocol {
    func getAll() -> [Product]
    func addProduct(product: Product)
    func updateProduct(product: Product)
    func deleteProduct(product: Product)
}
class ProductService: NSObject, ProductServiceProtocol {
    let productRepository = ProductRepository()

    func getAll() -> [Product] {
        return productRepository.getAll()
    }

    func addProduct(product: Product) {
        productRepository.addProduct(product)
    }

    func updateProduct(product: Product) {
        productRepository.updateProduct(product)
    }

    func deleteProduct(product: Product) {
        productRepository.deleteProduct(product)
    }
}

2.4. Xây dựng UI và các controller

Về giao diện của chương trình, chúng ta sẽ cần 1 màn quản lý danh sách product, 1 màn cho phép thêm và sửa thông tin của từng product, chi tiết như sau:

81797548A6F0DC8E3CBA08D5FEC04157.png

Bạn sẽ cần phải thêm toolbar vào trong MainWindowController, toolbar có 3 nút Add, Delete, Edit:

AA46DEB9469824A3921BB8815C48016D.png

MainViewController chúng ta cần thêm 1 NSTableView có 2 cột Name và Price. MainViewController có 1 segue dạng Sheet sang ProductViewController.

Ghi chú: segue có 1 số dạng cũng khá tương đồng với iOS bao gồm:

  • Show: hiện view controller ở dạng cửa sổ mới
  • Modal: hiện view controller ở dạng cửa sổ modal (nằm trên cùng và không cho phép thao tác với các cửa sổ khác)
  • Sheet: hiện view controller ở 1 sheet - 1 dạng view trượt xuống từ thanh tiêu đề của app
  • Popup: hiện view controller ở trong 1 một cửa sổ dạng bong bóng
  • Custom: loại tùy chỉnh

ProductViewController gồm 2 NSTextField cho việc nhập Name và Price và 2 NSButton cho việc xác nhận và hủy bỏ.

Trong OSX, mỗi đối tượng của class NSWindowController quản lý một window, bao gồm:

  • Tải và hiện thị window
  • Đóng window khi cần
  • Thay đổi tiêu đề cửa sổ
  • Lưu trữ frame (kích thước, vị trí) của cửa sổ trong database mặc định

Trong iOS không có khái niệm window controller do chỉ có duy nhất 1 window cho 1 ứng dụng.

NSViewController trong Mac OSX cũng tương đồng với UIViewController trong iOS, NSViewController quản lý các view và có các hàm phản ánh vòng đời của view:

  • viewDidLoad
  • viewWillAppear
  • viewDidAppear
  • viewWillDisappear
  • viewDidDisappear

Để kéo @IBAction, @IBOutlet chúng ta cũng thực hiện tương tự như iOS thông qua Assistant editor:

B5E0E87A45FC235526CDDB42E859B075.png

MainWindowController

Không giống như iOS, hàm windowDidLoad() của MainWindowController sẽ chạy trước applicationDidFinishLaunching(_:) của AppDelegate nên ta sẽ đặt các đoạn code khởi tạo ở đây.

Ta sẽ khởi tạo MagicalRecord qua dòng lệnh:

MagicalRecord.setupAutoMigratingCoreDataStack()

MainWindowController sẽ xử lý sự kiện người dùng thao tác với toolbar và đưa việc xử lý này cho MainViewController qua MainWindowControllerDelegate

protocol MainWindowControllerDelegate: class {
    func mainWindowControllerClickedAddButton()
    func mainWindowControllerClickedDeleteButton()
    func mainWindowControllerClickedEditButton()
}

class MainWindowController: NSWindowController {

    weak var delegate: MainWindowControllerDelegate?

    override func windowDidLoad() {
        super.windowDidLoad()

        MagicalRecord.setupAutoMigratingCoreDataStack()

        window?.titleVisibility = NSWindowTitleVisibility.Hidden

        let mainViewController = self.contentViewController as? MainViewController
        mainViewController?.window = self.window
        self.delegate = mainViewController
    }

    @IBAction func onAddButtonClicked(sender: NSToolbarItem) {
        delegate?.mainWindowControllerClickedAddButton()
    }

    @IBAction func onDeleteButtonClicked(sender: NSToolbarItem) {
        delegate?.mainWindowControllerClickedDeleteButton()
    }

    @IBAction func onEditButtonClicked(sender: NSToolbarItem) {
        delegate?.mainWindowControllerClickedEditButton()
    }
}

MainViewController

MainViewController có nhiệm vụ hiển thị danh sách product thông qua một table view. Cũng giống như iOS việc này được thực hiện thông qua việc implement NSTableViewDataSource, và NSTableViewDelegate của NSTableView.

MainViewController còn có nhiệm vụ thực hiện các chức năng thêm, sửa, xóa product. Trong đó việc thêm và sửa sẽ được thực hiện thông qua ProductViewController, chúng ta gọi ProductViewController thông qua 1 segue có kiểu Sheet được thiết lập tại storyboard.

Tại chức năng xóa, chúng ta cũng cho phép người dùng xác nhận việc xóa thông qua việc hiển thị một NSAlert theo kiểu Sheet thông qua hàm beginSheetModalForWindow(_:completionHandler:) của alert.

Mỗi khi thực hiện xong 1 tác vụ, chúng ta sẽ tải lại danh sách product thông qua hàm fetchProduct()

class MainViewController: NSViewController {

    @IBOutlet weak var tableView: NSTableView!

    weak var window: NSWindow!

    var productService: ProductServiceProtocol = ProductService()

    var products: [Product]!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do view setup here.

        tableView.setDataSource(self)
        tableView.setDelegate(self)
    }

    override func viewDidAppear() {
        super.viewDidAppear()
        fetchProduct()
    }

    private func fetchProduct() {
        products = productService.getAll()
        tableView.reloadData()
    }

    override func prepareForSegue(segue: NSStoryboardSegue, sender: AnyObject?) {
        if segue.identifier == "sheetProduct" {
            let controller = segue.destinationController as! ProductViewController
            controller.delegate = self
            controller.product = sender as? Product
        }
    }
}

extension MainViewController: NSTableViewDataSource {
    func numberOfRowsInTableView(tableView: NSTableView) -> Int {
        return products?.count ?? 0
    }
}

extension MainViewController: NSTableViewDelegate {
    func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
        var view: NSTableCellView?

        let product = products[row]

        if tableColumn?.identifier == "NameColumn" {
            view = tableView.makeViewWithIdentifier("NameCell", owner: self) as? NSTableCellView
            view?.textField?.stringValue = product.name
        }
        else if tableColumn?.identifier == "PriceColumn" {
            view = tableView.makeViewWithIdentifier("PriceCell", owner: self) as? NSTableCellView
            view?.textField?.stringValue = "\(product.price)"
        }

        return view
    }
}

extension MainViewController: MainWindowControllerDelegate {
    func mainWindowControllerClickedAddButton() {
        self.performSegueWithIdentifier("sheetProduct", sender: nil)
    }

    func mainWindowControllerClickedDeleteButton() {
        let selectedRow = tableView.selectedRow
        if selectedRow >= 0 && selectedRow < products.count {
            let product = products[selectedRow]

            let alert = NSAlert()

            alert.messageText = "Are you sure you want to delete product: " + product.name
            alert.informativeText = "You can't undo this action."
            alert.addButtonWithTitle("Delete")
            alert.addButtonWithTitle("Cancel")
            alert.alertStyle = NSAlertStyle.WarningAlertStyle

            alert.beginSheetModalForWindow(self.window!, completionHandler: { (response) -> Void in
                if response == NSAlertFirstButtonReturn {
                    self.productService.deleteProduct(product)
                    self.fetchProduct()
                }
            })
        }
    }

    func mainWindowControllerClickedEditButton() {
        let selectedRow = tableView.selectedRow
        if selectedRow >= 0 && selectedRow < products.count {
            let product = products[selectedRow]

            self.performSegueWithIdentifier("sheetProduct", sender: product)
        }
    }
}

extension MainViewController: ProductViewControllerDelegate {
    func productViewControllerDone(sender: ProductViewController) {
        let product = sender.product
        product.modificationDate = NSDate()

        if product.id == "" {
            product.id = NSUUID().UUIDString
            product.creationDate = NSDate()
            productService.addProduct(product)
        }
        else {
            productService.updateProduct(product)
        }
        fetchProduct()
    }
}

ProductViewController

ProductViewController cho phép người dùng thêm và sửa thông tin sản phẩm bao gồm tên và giá sản phẩm. Việc người dùng nhấn xác nhận hay hủy bỏ sẽ được truyền về MainViewController thông qua ProductViewControllerDelegate

Sau đó căn cứ vào id của sản phẩm, chúng ta sẽ biết là sản phẩm được tạo mới hay chỉ là cập nhật thông tin để tiến hành xử lý tương ứng tại MainViewController

protocol ProductViewControllerDelegate: class {
    func productViewControllerDone(sender: ProductViewController)
}

class ProductViewController: NSViewController {

    @IBOutlet weak var nameTextField: NSTextField!

    @IBOutlet weak var priceTextField: NSTextField!

    var product: Product!

    weak var delegate: ProductViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()

        if product != nil {
            nameTextField.stringValue = product.name
            priceTextField.stringValue = String(product.price)
        }
    }

    @IBAction func onOKButtonClicked(sender: NSButton) {
        if product == nil {
            product = Product()
        }

        product.name = nameTextField.stringValue
        product.price = (priceTextField.stringValue as NSString).doubleValue

        delegate?.productViewControllerDone(self)

        self.dismissController(nil)
    }

    @IBAction func onCancelButtonClicked(sender: NSButton) {
        self.dismissController(nil)
    }
}

2.5. Chạy demo

Danh sách sản phẩm

ADCD7BC70AD6F936D01113830C5047CB.png

Thêm sản phẩm

F4B6FE16402958233505EF3E9186A1D9.png

Xóa sản phẩm

B98DE33566D5031CD7015FD4E6CECD0F.png

3. Kết luận

Như vậy chúng ta đã hoàn thành một phần mềm đơn giản trên Mac OS X, hi vọng bài viết này sẽ giúp các bạn có thêm cảm hứng và ý tưởng để cho ra ứng dụng của riêng mình trên Mac App Store.

Các bạn có thể download source code tại đây