最後的潤色

使用 MVP 最大的好處,不只是單純的各類別耦合性降低,耦合性降低有其他設計模式都可以使用,最大的好處是在於易於維護與修改,以及擴充功能。

而筆者尤其推薦 MVP 設計模式,是 MVP 設計模式甚至可以幫助我們在開發階段時的構思。

我們在之前的練習中,已經寫了不少功能,而且都用各類不同的設計模式封裝好了,而在封裝的基礎上,也應該發現目前的 MainViewController 是有點雜亂的。

所以我們把動態排版的邏輯,再用一個PhotosView類別封裝起來。

import UIKit

class PhotosView: UIScrollView {

    private var mPhotoViews :[PhotoView]    = [PhotoView]()
    private var mTotals     :UInt           = 0
    private let mBoardWidth :CGFloat        = 5
    private var mNumX       :UInt           = 4

    var photoViews:[PhotoView]{
        return mPhotoViews
    }

    init(aNumX:UInt,aTotals:UInt){
        mTotals = aTotals
        mNumX = aNumX
        for _ in 0..<mTotals {
            let _photoView:PhotoView = PhotoView()
            mPhotoViews.append(_photoView)
        }
        super.init(frame: CGRect.zero)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func drawRect(rect: CGRect) {
        // Drawing code
        self.contentSize.width = rect.width
        createLayout()
        self.contentSize.height = mPhotoViews.last!.frame.origin.y + mPhotoViews.last!.frame.height + mBoardWidth
    }

    private func getPhotoViewWidthAndHeight() -> CGSize{
        let _dWidth:CGFloat = (self.contentSize.width - mBoardWidth * CGFloat(mNumX + 1))  / CGFloat(mNumX)
        let _dHeight:CGFloat = _dWidth * 3 / 4
        return CGSize(width: _dWidth, height: _dHeight)
    }

    private func createLayout(){
        let _photoSize:CGSize = self.getPhotoViewWidthAndHeight()
        for _index:UInt in 0..<mTotals {
            let _photoView:PhotoView = mPhotoViews[Int(_index)]
            let _dX:CGFloat = mBoardWidth + CGFloat(_index % mNumX) * (_photoSize.width + mBoardWidth)
            let _dY:CGFloat = mBoardWidth + CGFloat(_index / mNumX) * (_photoSize.height + mBoardWidth)
            _photoView.frame = CGRectMake( _dX, _dY , _photoSize.width, _photoSize.height)
            self.addSubview(_photoView)
        }
    }
}

然後,我們再建立一個新的UIViewController,取名為FlickrBroswerViewController.swift,並把AppleDelegate.swift裡的mWindow.rootViewController改為FlickrBroswerViewController

    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {

        mWindow.rootViewController = FlickrBroswerViewController()
        mWindow.makeKeyAndVisible()

        return true
    }

然後,再回到FlickrViewController我們開始建立由 MVP 模式下建構的程式。

import UIKit

class FlickrBroswerViewController: UIViewController {

    private var mFlickrModel        :FlickrModel
    private var mController         :Controller
    private var mPhotoViewsContainer:PhotosView?
    private var mInfoView           :InfoBarView?

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: NSBundle?) {
        mFlickrModel    = FlickrModel()
        mController     = Controller(aModel: mFlickrModel)

        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

        mPhotoViewsContainer    = PhotosView(aNumX: 3, aTotals: mFlickrModel.perPage)
        mInfoView               = InfoBarView()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = UIColor.whiteColor()

        let _infoBarHeight:CGFloat = 40
        mPhotoViewsContainer!.frame = CGRectMake(0, 0, self.view.bounds.width, self.view.bounds.height - _infoBarHeight)
        self.view.addSubview(mPhotoViewsContainer!)
        mPhotoViewsContainer!.backgroundColor = UIColor.redColor()

        mInfoView!.frame = CGRectMake(0, self.view.bounds.height - 40, self.view.bounds.width, 40)
        self.view.addSubview(mInfoView!)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

現在的專案的視覺架構已經跟之前不同了,我們也不能直接把PhotoPresenter直接用在現在的 PhotosView中,而且我們還想要同時處理 InfoBarView 的文字顯示,那該怎麼做呢?

我們在建立一個新的檔案PhotosPresenter,但是別忘記繼承Presenter,然後我們在Presenter這裡面,只要好好撰寫互動的呈現部分即可:

import Foundation
import LionEvents

class PhotosPresenter: Presenter {
    init(aContainer: PhotosView) {
        super.init(aView: aContainer)
    }

    override func onFlickrDataCompleteHandler(aEvent: Event) {
        let _dataPageIndex:UInt = aEvent.information as! UInt

        if _dataPageIndex == self.model?.currentPageIndex {
            let _photoVOs:[PhotoVO] = self.model!.getCurrentPhotos()

            if let _photosView:PhotosView = self.view as? PhotosView{
                for _photoView:PhotoView in _photosView.photoViews {
                    let _index:Int = _photosView.photoViews.indexOf(_photoView)!
                    if _index < _photoVOs.count {
                        if let _image:UIImage = ImageCache.sharedInstance().getImage(_photoVOs[_index].id + "_z.png"){
                            _photoView.setImage(_image)
                        }else{
                            let _imageURL:String = _photoVOs[_index].url_z
                            _photoView.downloadImage(_imageURL,aCacheFileName: _photoVOs[_index].id + "_z.png")
                        }
                    }else{
                        _photoView.downloadImage(nil,aCacheFileName: nil)
                    }
                }
            }
        }
    }

    override func onThemeStyleChangeHandler(aEvent: Event) {
        if let _photosView:PhotosView = self.view as? PhotosView{
            for _photoView:PhotoView in _photosView.photoViews {
                _photoView.setHighlight(self.model!.isNight)
            }
        }
        if self.model!.isNight {
            self.view.backgroundColor = UIColor.blackColor()
        }else{
            self.view.backgroundColor = UIColor.whiteColor()
        }
    }
}

先修改了init的建構式,要特別讓其他類別都無法塞進來,因為我們在override func onFlickrDataCompleteHandler這裡面所寫的動態排版邏輯,只適用於PhotosView而已。

其他會用到的事件,就是onThemeStyleChangeHandler而已。

下一步,InforBarView也要依據事件也改變內容該怎麼處理呢? 跟PhotosPresenter一樣的方式處理。

import Foundation
import LionEvents

class InfoBarPresenter: Presenter {

    override func onThemeStyleChangeHandler(aEvent: Event) {
        if let _infoBar:InfoBarView = self.view as? InfoBarView{
            _infoBar.setHighlight(self.model!.isNight)
        }
    }

    override func onPageIndexChangeHandler(aEvent: Event) {
        if let _infoBar:InfoBarView = self.view as? InfoBarView{
            _infoBar.setText("Page Index:\(self.model!.currentPageIndex)")
        }
    }

    override func onPhotoSelectChangeHandler(aEvent: Event) {

    }
}

我們再回到 FlickrBroswerViewController,再把剛剛建立好的PhotosPresenterInfoBarPresenter加進去,除此以外再加上Controller,讓PhotosView也能當控制器。

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = UIColor.whiteColor()

        let _infoBarHeight:CGFloat = 40
        mPhotoViewsContainer!.frame = CGRectMake(0, 0, self.view.bounds.width, self.view.bounds.height - _infoBarHeight)
        self.view.addSubview(mPhotoViewsContainer!)
        mPhotoViewsContainer!.backgroundColor = UIColor.redColor()

        mInfoView!.frame = CGRectMake(0, self.view.bounds.height - 40, self.view.bounds.width, 40)
        self.view.addSubview(mInfoView!)

        let _photoPresenter:PhotosPresenter = PhotosPresenter(aContainer: mPhotoViewsContainer!)
        _photoPresenter.model = mFlickrModel

        let _infoPresenter:InfoBarPresenter = InfoBarPresenter(aView: mInfoView!)
        _infoPresenter.model = mFlickrModel

        mController.addStyleChangeController(mPhotoViewsContainer!)

        mFlickrModel.loadFlickrData(1)
        mFlickrModel.isNight = false

    }

執行看看,現在已經有佈景主題切換的功能了!

下一步,我們要翻頁的功能!而且是在infoBarView上滑動就可以翻頁,我們該在哪邊追加功能呢?

照 MVP 的邏輯來看,翻頁的動作是Controller負責,Controller才會改變Model的數據資料。

// Controller.swift
    func addPageIndexChangeController(aView:UIView){
        let _swipeLeftGesture:UISwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: Selector("onPageSwipeHndler:"))
        _swipeLeftGesture.direction = UISwipeGestureRecognizerDirection.Left
        aView.addGestureRecognizer(_swipeLeftGesture)

        let _swipeRightGesture:UISwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: Selector("onPageSwipeHndler:"))
        _swipeRightGesture.direction = UISwipeGestureRecognizerDirection.Right
        aView.addGestureRecognizer(_swipeRightGesture)
    }

    @objc private func onPageSwipeHndler(aSender:UISwipeGestureRecognizer) {
        if aSender.direction == UISwipeGestureRecognizerDirection.Left {
            self.model?.currentPageIndex--
        }else{
            self.model?.currentPageIndex++
        }
        if self.model?.currentPageIndex < 1 {
            self.model?.currentPageIndex = 1
        }
        if self.model?.currentPageIndex > 5 {
            self.model?.currentPageIndex = 5
        }
        print("page index:\(self.model?.currentPageIndex)")
    }

然後再FlickrBroswerViewController找到mController.addStyleChangeController再追加下面程式:

mController.addPageIndexChangeController(mInfoView!)

在編譯到模擬器或實體機器看看效果。

到這階段,可以在玩看看如果 Controller 的這兩行,丟入的UIView的實體不同,會有怎樣的效果?

受歡迎的500張照片瀏覽已經完成了,我們想要再追加功能時要怎麼做呢?

我們再追加一個大圖顯示的功能吧!

在新建一個類別,不過這類別我們不是繼承UIView,而是直接繼承PhotoView即可,因為大圖顯示的功能,原本的PhotoView就已經有了。

所以我們先修改PhotoView這類別,追加一個屬性:

    var imgeView:UIImageView{
        return mImageView
    }

然後我們再完成LargePhotoView這個類別。

class LargePhotoView: PhotoView {
    static let CLOSE:String = "close"

    private var mInfoBarView    :InfoBarView    = InfoBarView()
    private var mCloseButton    :UIButton       = UIButton()
    private var mDownLoadButton :UIButton       = UIButton()
    private var mBackgroundView :UIView         = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = UIColor.clearColor()
        mBackgroundView.backgroundColor = UIColor.blackColor()
        mBackgroundView.alpha = 0.7

        mInfoBarView.infoLabel.numberOfLines = 2

        self.addSubview(mBackgroundView)
        self.addSubview(self.imageView)
        self.addSubview(mInfoBarView)
        self.addSubview(mCloseButton)
        self.addSubview(mDownLoadButton)

        mDownLoadButton.setTitle("Download", forState: UIControlState.Normal)
        mCloseButton.setTitle("Close", forState: UIControlState.Normal)

        mDownLoadButton.layer.cornerRadius = 3.0
        mCloseButton.layer.cornerRadius = 3.0

        mDownLoadButton.backgroundColor = UIColor.goldColor()
        mCloseButton.backgroundColor = UIColor.goldColor()

        mDownLoadButton.addTarget(self, action: Selector("onDownLoadHandler:"), forControlEvents: UIControlEvents.TouchUpInside)
        mCloseButton.addTarget(self, action: Selector("onCloshHandler:"), forControlEvents: UIControlEvents.TouchUpInside)
    }

    @objc private func onCloshHandler(aSender:UIButton){
        //self.removeFromSuperview()
        let _event:Event = Event(aType: LargePhotoView.CLOSE)
        self.dispatchEvent(_event)        
    }

    @objc private func onDownLoadHandler(aSender:UIButton){
        if let _image:UIImage = self.imgeView.image {
            UIImageWriteToSavedPhotosAlbum(_image, self, Selector("onSaveImageCompleteHandler:::"), nil)
        }else{
            print("no image can save!")
        }
    }

    func onSaveImageCompleteHandler(aImage: UIImage, _ aError: NSError?,_ aContextInfo:UnsafePointer<Void>) {
        if aError == nil {
            print("儲存內容到相簿成功!")
        } else {
            print("儲存內容到相簿失敗!")
        }
    }


    deinit{
        mDownLoadButton.removeTarget(self, action: Selector("onDownLoadHandler:"), forControlEvents: UIControlEvents.TouchUpInside)
        mCloseButton.removeTarget(self, action: Selector("onCloshHandler:"), forControlEvents: UIControlEvents.TouchUpInside)
    }

    func setPhotoVO(aVO:PhotoVO){
        mInfoBarView.setText("Title:\(aVO.title)\nOwner:\(aVO.owner)")
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func touchesEnded(touches: Set<UITouch>, withEvent event: UIEvent?) {
        let _touch:UITouch = touches.first!
        let _touchendPoint:CGPoint = _touch.locationInView(self)
        if _touchendPoint.x > 0 && _touchendPoint.x < self.bounds.width && _touchendPoint.y > 0 && _touchendPoint.y < self.bounds.height {
            let _event:Event = Event(aType:PhotoView.TOUCH_UP_INSIDE, aBubbles: true)
            _event.information = _touchendPoint
            dispatchEvent(_event)
        }
    }

    // Only override drawRect: if you perform custom drawing.
    // An empty implementation adversely affects performance during animation.
    override func drawRect(rect: CGRect) {
        // Drawing code
        super.drawRect(rect)
        mBackgroundView.frame = rect
        mInfoBarView.frame = CGRectMake(0, rect.height - 90, rect.width, 90)
        mDownLoadButton.frame = CGRectMake(rect.width / 2 + 3, rect.height - 113, rect.width / 2 - 3, 20)
        mCloseButton.frame = CGRectMake(0, rect.height - 113, rect.width / 2 - 3, 20)
    }
}

版型的設計我這邊就不多加詳述,說明一下這個類別的功能

  • 有 PhotoView 的功能,可以下載圖片也可以直接把 UIImage丟進來。

  • 追加了兩個文字功能,分別顯示照片標題與擁有者名稱。

  • 追加了 CLOSE 的事件。

  • 追加了照片儲存到相簿的功能按鈕。

現在,我們要實踐PhotoView點選後,然後跳出LargePhotoView要怎麼做呢?各功能的程式碼應該要寫在什麼地方?

好好思考一下!

第一步, LargePhotoView 要呈現大圖、擁有者、照片標題等較多的詳細資訊,甚至日後可能會再追加或是修改要呈現的資料,所以一定要能夠知道目前被點選的 PhotoVO為何。

所以我們先在FlickrModel追加一個屬性

    private var mCurrentPhotoVO     :PhotoVO?
    var currentPhotoVO:PhotoVO?{
        get{
            return mCurrentPhotoVO
        }
        set{
            mCurrentPhotoVO = newValue
            let _event:Event = Event(aType: FlickrModel.PHOTO_SELECT_CHANGE)
            self.dispatchEvent(_event)
        }
    }

只要currentPhotoVO值被改變,就廣播FlickrModel.PHOTO_SELECT_CHANGE事件。

我們再完成 Controller 的部分:

    func addPhotoViewSelectController(aPhotosContainer:PhotosView){
        aPhotosContainer.addEventListener(PhotoView.TOUCH_UP_INSIDE, onPhotoViewTouchHandler)
    }

    private func onPhotoViewTouchHandler(aEvent: Event){
        let _photosView:PhotosView = aEvent.currentTarget as! PhotosView
        if let _photoView:PhotoView = aEvent.target as? PhotoView {
            let _index:Int = _photosView.photoViews.indexOf(_photoView)!
            let _vo:PhotoVO = self.model!.getCurrentPhotos()[_index]
            self.model!.currentPhotoVO = _vo
        }
    }

只要PhotosView一接受到PhotoView.TOUCH_UP_INSIDE的事件,就找到對應的PhotoVO並且寫進FlickrModel

當然,我們可以再追加LargePhotoView被關閉的互動控制:

    func addLargePhotoViewController(aLargePhotoView:LargePhotoView){
        aLargePhotoView.addEventListener(LargePhotoView.CLOSE,onLargePhotoCloseHandler)
    }

    private func onLargePhotoCloseHandler(aEvent: Event) -> Void {
        self.model?.currentPhotoVO = nil
    }

只要LargePhotoView廣播LargePhotoView.CLOSE事件,就把self.model?.currentPhotoVO清空

這樣就只剩下LargePhotoView的呈現動作,也就是LargePhotoPresenter

import UIKit
import LionEvents

class LargePhotoPresenter: Presenter {
    private let mLargePhotoView:LargePhotoView
    init(aContainerView:UIView, aLargePhotoView: LargePhotoView){
        mLargePhotoView = aLargePhotoView
        super.init(aView: aContainerView)
    }

    override func onPhotoSelectChangeHandler(aEvent: Event) {
        if let _vo:PhotoVO = self.model?.currentPhotoVO {
            mLargePhotoView.setPhotoVO(_vo)
            mLargePhotoView.alpha = 0.0

            if let _image:UIImage = ImageCache.sharedInstance().getImage(_vo.id + "_q.png"){
                mLargePhotoView.setImage(_image)
            }else{
                let _imageURL:String = _vo.url_z
                mLargePhotoView.downloadImage(_imageURL,aCacheFileName: _vo.id + "_q.png")
            }

            self.view.addSubview(mLargePhotoView)
            UIView.animateWithDuration(0.3, animations: { () -> Void in
                self.mLargePhotoView.alpha = 1.0
            })

        }else{
            UIView.animateWithDuration(0.3, animations: { () -> Void in
                self.mLargePhotoView.alpha = 0.0
                }, completion: { (aFinished:Bool) -> Void in
                    self.mLargePhotoView.removeFromSuperview()
            })
        }
    }
}

我們再回到 FlickrBroswerViewController 呼叫我們剛剛寫的 MVP 吧!

class FlickrBroswerViewController: UIViewController {

    private var mFlickrModel        :FlickrModel
    private var mController         :Controller
    private var mPhotoViewsContainer:PhotosView?
    private var mInfoView           :InfoBarView?
    private var mLargePhotoView     :LargePhotoView?

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: NSBundle?) {
        mFlickrModel    = FlickrModel()
        mController     = Controller(aModel: mFlickrModel)

        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

        mPhotoViewsContainer    = PhotosView(aNumX: 3, aTotals: mFlickrModel.perPage)
        mInfoView               = InfoBarView()
        mLargePhotoView         = LargePhotoView(frame: UIScreen.mainScreen().bounds)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = UIColor.whiteColor()

        let _infoBarHeight:CGFloat = 40
        mPhotoViewsContainer!.frame = CGRectMake(0, 0, self.view.bounds.width, self.view.bounds.height - _infoBarHeight)
        self.view.addSubview(mPhotoViewsContainer!)
        mPhotoViewsContainer!.backgroundColor = UIColor.redColor()

        mInfoView!.frame = CGRectMake(0, self.view.bounds.height - 40, self.view.bounds.width, 40)
        self.view.addSubview(mInfoView!)

        let _photoPresenter:PhotosPresenter = PhotosPresenter(aContainer: mPhotoViewsContainer!)
        _photoPresenter.model = mFlickrModel

        let _infoPresenter:InfoBarPresenter = InfoBarPresenter(aView: mInfoView!)
        _infoPresenter.model = mFlickrModel

        let _largePhotoPresenter:LargePhotoPresenter = LargePhotoPresenter(aContainerView: self.view, aLargePhotoView: mLargePhotoView!)
        _largePhotoPresenter.model = mFlickrModel

        mController.addStyleChangeController(mInfoView!)
        mController.addPageIndexChangeController(mPhotoViewsContainer!)

        mController.addPhotoViewSelectController(mPhotoViewsContainer!)
        mController.addLargePhotoViewController(mLargePhotoView!)

        mFlickrModel.loadFlickrData(1)
        mFlickrModel.isNight = false

    }


    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}

在 MVP 模式之下,任何功能都可以被拆解成 Model 、View、Presenter、Controller,而且如果有需要任何修改,就是一直追加功能,而舊有的功能可以移除,也可以放著不管。

在回顧一下我們完成這功能的時候,其實在實踐 MVP 模式時,新功能的追加甚至沒有想清楚功能整體的邏輯,只是考慮到會用到什麼功能就放在哪裡而已。

越複雜的互動邏輯,越適合 MVP 來幫助思考。

最後我們看一下最終成果吧:

完成到這一步的Demo:

Last updated