记我的本科毕业设计——绿茶

本科的最后一个项目——毕业设计,我选择了困难模式
第一次自己做原型、画 Sketch 、设计产品
第一次使用 Swift 独立开发项目
这款 App 叫做 绿茶, 虽然差一个字就成了奇怪的名字, 不过其实很小清新的! 虽然没有上线, 但是其实很完整
戳我进项目地址

背景

不知不觉博客已经荒置了快1年了,之前的最后一篇文章还是在前司的时候,后来跟女朋友过了一段清闲的小日子,做完了本科的毕设,顺利地拿到了毕业证书,然后浪了一个暑假。

研究生开学也有半月,虽然接了外包,有了兼职,当了助教,老板那边还有成堆的活,但是趁着中秋假期,忙里头像把这篇很久以前就想写的文章写一下。

在我们学院,本科生的毕业设计一般分四类:导师选题、实习项目、自主选题以及实训。

  • 导师选题:顾名思义,就是老师给一个命题,通过双向选择确定学生来以这个命题作为毕设题目完成对应项目,一般都是已有的明确需求或者是研究性质的命题,但是不会太难,大部分是以团队为单位

  • 实习项目:由于院里同学大部分大四都会出去实习,所以学院允许以在实习单位所做的项目为命题写毕业论文,这一般意味着不需要特地写一个项目只需要写一篇论文就能完成了,所以是最简单的方式。

  • 自主选题:简而言之,就是自己想一个题目,然后把它实现出来

  • 实训:针对那些实在没有项目可写的同学,学院从企业、政府要来一些项目让同学组队封闭开发,以此作为毕设。

于是,最后我放弃了实习项目这个简单模式,作死地选择了 自主选题 这条困难模式 =.=

关于项目

简而言之,这是一个奶茶集点卡应用,为了实现起来方便,我把商家操作和客户操作都放在了一个客户端,普通用户可以通过创建店铺成为商家,发布活动,客户可以查找到附近进行集点活动的店铺,并且在线上完成集点收集以及兑换的业务。具体流程见这个 Gif 即可。

Demo GIF Animation

项目设计

作为一名软件工程出身的科班生,起初,我很老实地写了一份详实的需求分析文档。

画了用例图,像这样:

用例图

写了一大堆用例描述文档,像这样:

用例文档描述

后来发现还不如一个 Sketch 文件, 像这样:
戳我下载Sketch源文件

当然一开始做那些文档化的东西,纯粹是觉得日后写毕设的时候用得着,兴许对之后的开发会有一定的帮助。而实际上就是,对于这样的个人项目(甚至是小团队项目),标准的、完备的文档其实是没有太大必要的,一个 Sketch 或者 一个 Axure 就能解决大部分的需求问题。

项目开发

LeanCloud

这样的一个项目肯定是需要服务端,需要数据持久化服务的。 而作为一个个人项目, 既要做设计、又要写服务端、又要写客户端实在是一件麻烦事儿,服务端那边还要部署、配置,其实是我最不喜欢的。

所以针对这样一个不算复杂的项目,我选择尝试使用第三方数据服务平台,国内比较有名的就是 LeanCloud 了,口碑算是不错的。

阅读了一下它的官方文档,还算详实, 唯一感到的不足就是 SDK 侵入性太强, 对于每个 Model 都需要继承其基类, 而数据的填充则是基于 runtime 的, 又因为 SDK 是用 Objective-C 来编写的, 对 Swift 的支持真的不是很好, 在开发的过程中遇到了不少坑。

集成 LeanCloud

集成了 LeanCloud 也就意味着项目没有了所谓的网络层, 所有的数据的增删改查都直接通过调用其 SDK 中基类的对应方法就行了, 而为了方便日后的修改(比如去除 LeanCloud 的依赖), 在所有的业务层中均没有涉及到对 LeanCloud 的任何依赖, 所有的数据处理的操作均通过自定义 Model 的扩展来实现, 比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// MARK: - LeanCloud
extension Activity {
func queryShopInfo(completion:Shop->Void) {
//获取shop的查询对象
let query = Shop.query()
query.includeKey("activitys")
//根据shopId查询shop
query.getObjectInBackgroundWithId(self.shopId) { (shop, error) in
if let shop = shop as? Shop {
//回调
self.shopInfo = shop
completion(shop)
}
}
}
}

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|----- Protocal 协议
|
|
|----- Extension 扩展 |----------------LoginAndRegister 登录注册
| |
| |
|----- Section MVVM 架构目录 -------- |--------------- Setting 设置 Tab
| |
| |
|----- Tools 各种工具 |--------------- Message 消息 Tab
| |
| |
|----- Vender 第三方依赖 |----------------LoyaltyCards 卡包 Tab
|
|
|----------------Activity 活动 Tab

当然,第三方库的集成还是使用的 CocoaPods ,只是一些无法使用 CocoaPods 集成的库存放在了 Vender 目录中。

这里所谓的 MVVM 架构,实际上是参考了在 @Swift 大会上李洁信讲到的基于 POP 的 MVVM 架构这个说法, 当时觉得听起来是个不错的实践,便在这个项目中应用了一下,实际操作中也的确感受到了其好处,同时也发现了一些问题。下面是他的演讲 PPT, 感兴趣的可以具体看下。
POP In Swift

POP In Swift

下面我就简单讲一下我在项目中如何使用面向协议编程来解决一些实际开发中遇到的问题, 其中的大部分思路都是我从 @Swift 大会上学到的, 也是在上面的 PPT 中可以看到的。

通过协议扩展来减少重复代码与解耦

在 iOS 应用开发中,常常会编写一个功能强大的 Controller 基类,所有的 Controller 集成这个基类,在基类中完成一些基本的配置,并加入一些经常使用 的方法供子类来调用。然而久而久之,这会使得项目变得越来越难以维护,一方 面并不是所有 Controller 都需要这些方法,这就意味着必须重载父类的方法才可 以去除这些逻辑。

举一个简单的例子,在应用中存在许多的界面都有一个共同的需求,他们通过 Present 而非 Push 的形式展示给用户,左上角有一个取消按钮,并且点击取 消按钮可以让该页面消失回到原先的页面,那么应该使用何种方式来实现这个需求呢?

继承或许是个不错的方法,写一个父类来完成所有的配置,将所有的这些界面均继承这个父类。听起来不错,然而如果其中的某几个界面又有共同的需求, 是不是意味着又要再加一层继承关系?

组合是一个更好的方法,可以写一个导航栏,让这些 Controller 持有这个自 定义的导航栏,所有的共同逻辑在这个自定义组件中完成,然而不得不承认,每 个 Controller 需要持有这些组件,并且需要管理这些事例的创建和释放,在使用 过程中通过间接变量,多了一层的结构。

针对这样的情况,本项目使用协议扩展的方式解决了这个问题,相比使用组合,并没有增加代码,但是却更容易复用。面向协议编程即接口而非具体实现, 充分解耦。下面是具体实现。

1
2
3
4
5
6
7
8
9
10
//定义协议 protocol ViewControllerPresentable { func configureNavigationItem()
} //协议扩展且限定遵循协议的是一个 UIViewController extension ViewControllerPresentable where Self:UIViewController { func configureNavigationItem(){ let leftButton = CancelButton.init(frame:CGRectMake(0, 0, 100, 40)) leftButton.setTitle("取消", forState: UIControlState.Normal) leftButton.setTitleColor(UIColor.whiteColor(), forState: UIControlState.Normal)
leftButton.addTarget(leftButton, action: #selector(CancelButton.onCancelButto nClicked), forControlEvents: UIControlEvents.TouchUpInside) leftButton.titleLabel?.font = UIFont.systemFontOfSize(UIFont.systemFontSize()) leftButton.contentHorizontalAlignment = UIControlContentHorizontalAlignment. Left let leftBarBarItem = UIBarButtonItem.init(customView: leftButton) self.navigationItem.leftBarButtonItems = [leftBarBarItem]
} } class CancelButton:UIButton {
func onCancelButtonClicked(){ self.viewController()?.dismissViewControllerAnimated(true, completion: nil)
} }
//编辑个人信息的界面,即从当前页面直接弹出,左上角有一个取消按钮 class EditUserInfoTableViewController:UITableViewController,ViewControllerPresentable { override func viewDidLoad() {
super.viewDidLoad()
self.configureNavigationItem() }
}

在上面代码中,定义了一个 ViewControllerPresentable 的协议,这 个协议中定义了方法 configureNavigationItem(),然而通过协议扩展的方式给这个协议添加了一个默认实现,这个默认实现仅在遵循协议的类为 UIViewController 或者是其子类中生效,加入了这个限制,在这个默认实现中就 可以调用 UIViewController 的方法了。在使用过程中,只需要让 EditUserTableViewController 遵循这个协议,并且调用方法,不用写任何实现即可以完成指定的功能。

基于POP的MVVM的设计模式的实践

在 Model 中,存在 name 和 icon 的属性,ViewModel 通过注入 Model 来生成对应的 titleData 和 imageData 分别对应 Model 的 name 和 icon ,只不过它们已经被处理成了可以直接渲染在 View上的数据,而处理的方法便是通过协议。下面看具体代码实现,以ActivitySimpleViewModel为例:

1
typealias ActivitySimplePresentable = protocol<TitlePresentable,WebIconPresentable,Checkable,LocationPresentable,SubTitlePresentable> //使用struct而非class是因为struct没有引用所带来的副作用,ViewModel是一个稳定的对象,struct中一旦有数据发生改变整个就被重新替换,这样可以通过持有者的didSet方法来刷新页面 struct ActivitySimpleViewModel:ActivitySimplePresentable { // 来自WebIconPresentable var iconName: String // 来自TitlePresentable var title: String // 来自LocationPresentable var location: String // 来自SubTitlePresentable var subTitle: String // 来自Checkable var isChecked: Bool? //注入activity来初始化 init(activity:Activity){ self.iconName = activity.avatar.url self.title = activity.name self.location = activity.locationName self.subTitle = "\(activity.likeCount)" self.isChecked = activity.isLikedByMySelf } }

ActivitySimpleViewModel的所有属性都定义与其遵循的协议,且本身没有任何实现,不用编写任何多余代码。下面是这些协议的代码:

1
protocol TitlePresentable { var title: String { get } var titleColor: UIColor { get } func updateTitleLabel(label:UILabel) } extension TitlePresentable { var titleColor: UIColor { return UIColor.globalTitleBrownColor() } func updateTitleLabel(label:UILabel) { label.text = self.title label.textColor = self.titleColor } } protocol WebIconPresentable { var iconName: String { get } func updateImageView(imageView: UIImageView) } extension WebIconPresentable { func updateImageView(imageView: UIImageView) { imageView.clipsToBounds = true imageView.contentMode = UIViewContentMode.ScaleAspectFill imageView.setImageWithUrlString(iconName) } } ....

这里只贴出了 TitlePresentable 和 WebIconPresentable 的代码,TitlePresentable 中定义了title 这个变量的 get 方法,意味着只要遵循了 TitlePresentable 的协议必须实现 title 这个属性,同时定义了 updateTitleLabel(label:UILabel)的方法,并提供了默认实现,在这个协议中,我们可以定义更多的属性即默认实现来控制 Title 的字体、颜色、背景等等,将这些处理工作全部封装在了协议扩展中,而 ViewModel 正常情况下不需要实现一行代码,当需要有个性化定制的时候只需要重写对应方法即可。

针对使用协议化定制的ViewModel,我们使用下面代码中的方式进行调用。

1
2
extension ActivityListDataSource: UITableViewDataSource { func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { let tableViewCell = tableView.dequeueReusableCell(indexPath: indexPath) as ActivityTableViewCell if let activity = self.activityList?[indexPath.row] { //创建activityViewModel let activityViewModel = ActivitySimpleViewModel(activity: activity) //渲染cell tableViewCell.render(activityViewModel) } return tableViewCell } }
class ActivityTableViewCell: UITableViewCell { @IBOutlet weak var starButton: UIButton! @IBOutlet weak var locationLabel: UILabel! @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var activityImageView: UIImageView! @IBOutlet weak var likeCountLabel: UILabel! func render(activityPresentable:ActivitySimplePresentable) { activityPresentable.updateImageView(activityImageView) activityPresentable.updateTitleLabel(nameLabel) activityPresentable.updateLocationLabel(locationLabel) activityPresentable.updateButton(starButton) activityPresentable.updateSubTitleLabel(likeCountLabel) } }

ActivityTableViewCell 甚至不需要依赖于 ActivityViewModel 或者 Activity,它仅仅依赖于ActivitySimplePresentable,并且通过传入UI组件的方法来完成对页面的渲染。

关于POP的一些思考

  • 面向协议和面向接口

    protocal 协议是苹果官方语言中的概念即协议, Objective-CSwift 中都有, 而到了 Swift 2.1 后,苹果官方更是推崇面向协议编程

    其实从作用和目的来说,协议和接口并没有多大差别,其目的就是定义某一类行为(功能、约定),然后让类(对象)遵循它从而实现它所规定的行为。

    再谈谈协议扩展,其实就是定义了默认行为,Java 中也有类似的语法功能——接口默认实现。

    简而言之,面向协议编程和面向接口编程从实际的作用和目的上是一致的(实际操作也没有多大区别),唯一的区别从个人理解上就是语义层面的,针对协议接口的不同语义可能导致在代码设计上会有一定的差别。

  • 耦合

    无论是协议还是扩展最大的一个好处其实就是解耦,帮助开发者开发出松耦合、可维护、可扩展的代码,对外暴露的仅仅是协议或者接口,隐藏其内容。 从软件工程的角度就是 信息隐藏 , 依赖接口或协议编程而非依赖实际的类编程,这点在上述的两点中都可以很容易看出来。

  • 基于 POP 的 MVVM 设计模式的局限性

    第一次看到这个模式, 其实感觉挺新鲜挺神奇的, 通过协议的组合以及协议的默认实现,可以减少大部分重复代码, 在处理 UI 渲染这块就编程了拼图式的将预置好的组件相拼接, 极大地提高了代码的复用率。

    但是在实际使用过程中,其实并没有那么神奇。 就好比我这个项目是一个规模相对比较小的项目,并没有太多的复杂界面,也没有太多的组件复用, 在这种情况下使用这种方式,造成的问题包括:

    • 大规模增加文件个数,且很多文件只有短短几行
    • 减少代码可读性,一个简单的 UI 组件绘制,需要牵扯到 3 - 4 处分布在不同文件中的代码

    但是相对的,当项目足够复杂,并且有需求将各种 UI 元素组件化的情况下, 这种模式便是值得学习和一试的了。

关于项目本身

其实个人依然觉得一个通用的可以同时提供给商家和客户的集点卡应用是有一定市场价值的,拿去融个天使轮甚至都没啥问题 =.= , 但是一定是没有太大的前景的,甚至做一个类似的微信应用,从推广和营销的角度更加合适, 所以最后也就成了一个练手项目了。

第一次使用 Swift 开发项目, 自我感觉依然没有摆脱使用 Objective-C 开发 iOS 应用的老惯性, 没有办法很自觉地使用 Swift 的思考方式来开发应用, 一些比较吸引人的特定也并没有用到。 可以看出,学习一门语言不单单是熟悉语法这么简单的事情,真的要做到理解和精通需要持续的项目积累以及源码阅读。

马上出 3.0 了。。还在犹豫要不要升级 =.=。