新浪微博开发笔记
iPhone 项目目标
- 项目掌控能力
- 工具使用能力
- 开发技巧能力
课程提纲
新浪微博接口地址
微博开放平台地址
微博接口文档地址
项目主题框架
走向工作岗位之后,一般会遇到两种工作情况:
新项目开发
- 通常在项目开始之前,公司的产品经理会提供完整的产品原型图,或功能设计文档
- 通过对这些文档的解读,能够梳理出目标项目的整体架构,从而协助项目框架的搭建
旧项目维护
- 很多老项目是缺乏文档的,这种情况在一些小公司中表现的尤为突出
- 要想快速上手一个老项目,首先运行项目,并且整理项目整体框架结构
- 然后用整理出的框架结构与代码结构相互印证,无疑可以对了解项目的整体架构起到重要的辅助
综上所述,无论是新项目,还是老项目,在开发之前确定项目的主体架构都是非常重要,也是十分必要的!
主体架构确认的好处
开发之前,明确项目的主体架构具有以下好处:
- 明确开发目标,项目一旦启动,始终锁定目标前进!
- 明确功能模块的数量,方便工期核算
- 根据开发进度,预判开发周期,及时与相关部门沟通、协调
- 根据主体架构搭建项目框架,方便团队开发,各个功能模块齐头并进,提高开发效率!
- 确定项目开发中的重点难点,提前安排攻关能力强的同事进行技术攻关,待需要时能够享受攻关成果,或者及时调整产品设计
- 新增或调整功能时,能够高屋建瓴,在最合适的位置添加相关功能模块
新浪微博
作为中国移动互联网的代表性产品之一,新浪微博涵盖了大量的移动互联网元素,通过对新浪微博的研究及模仿,可以:
- 对这些元素在实际产品中的应用有深入的了解和认识
- 知道如何在一个真实的项目中运用相关技术点
- 对大型项目的架构、开发及掌控有更全面的认识和理解
正如前文所述,在开始模仿之前,首先运行产品,掌握项目的整体架构,确定开发的主体功能非常重要!
新浪微博主体架构
对界面预览之后,可以发现新浪微博符合经典应用程序架构设计:
- 主视图控制器是一个
UITabbarController
- 包含四个
UINavigationController
,分别是- 首页
- 消息
- 发现
- 我
特殊之处:
-UITabbarController
中间有一个 “+” 按钮,点击该按钮能够 Modal 显示微博类型选择
界面,方便用户选择自己需要的微博类型 - 四个 UINavigationController
在用户登录前后显示的界面格式是不一样的 根原版新浪微博的区别
由于必须使用新浪微博官方的 API 才能够正常开发,换言之,如果没有登录系统是无法使用新浪微博提供的接口的!
基于上述原因,在实际开发中对未登录之前的界面设计进行简化
开源中国社区
官方网站
- 开源中国社区成立于2008年8月,其目的是为中国的IT技术人员提供一个全面的、快捷更新的用来检索开源软件以及交流使用开源经验的平台
- 目前国内有很多公司会将公司的项目部署在
OSChina
与 GitHUB
的对比
- 服务器在国内,速度更快
- 免费账户同样可以建立
私有
项目,而GitHUB
上要建立私有项目必须付费
使用
注册账号
- 建议使用网易的邮箱,使用其他免费邮箱可能会收不到验证邮件
添加 SSH 公钥,进入终端,并输入以下命令
- 开源中国帮助文档地址:帮助#ssh-keys
# 切换目录,MAC中目录的第一个字符如果是 `.` 表示改文件夹是隐藏文件夹$ cd ~/.ssh# 查看当前目录文件$ ls# 生成 RSA 密钥对# 1> "" 中输入个人邮箱# 2> 提示输入私钥文件名称,直接回车# 3> 提示输入密码,可以随便输入,只要本次能够记住即可$ ssh-keygen -t rsa -C "xxx@126.com"# 查看公钥内容$ cat id_rsa.pub
将公钥内容复制并粘贴至
测试公钥
# 测试 SSH 连接$ ssh -T git@git.oschina.net# 终端提示 `Welcome to Git@OSC, 刀哥!` 说明连接成功
- 新建项目
- 克隆项目
# 切换至项目目录$ cd 项目目录# 克隆项目,地址可以在项目首页复制$ git clone git@git.oschina.net:xxx/ProjectName.git
- 添加
gitignore
# ~/dev/github/gitignore/ 是保存 gitignore 的目录$ cp ~/dev/github/gitignore/Swift.gitignore .gitignore
- 提示:
- 可以从
https://github.com/github/gitignore
获取最新版本的gitignore
文件 - 添加
.gitignore
文件之后,每次提交时不会将个人的项目设置信息(例如:末次打开的文件,调试断点等)提交到服务器,在团队开发中非常重要
- 可以从
图片素材
素材对应的设备
1x | 2x | 3x |
---|---|---|
大小对应开发中的点 | 宽高是 1x 的两倍 | 宽高时 1x 的三倍 |
iPhone 3GS,可以省略 | iPhone 4iPhone 4siPhone 5iPhone 5siPhone 6 | iPhone 6+ |
与美工的配合
- 让美工在设计原型图时,按照
iPhone 6+
的分辨率设计 - 然后切图的时候,切两套即可
- 一套以 @3x 结尾,供 iPhone 6+ 使用
- 一套缩小 2/3,以 @2x 结尾,供小屏视网膜手机使用
提示:现在大多数应用程序还适配 iOS 6,下载的 ipa 包能够拿到图片素材,但是如果今后应用程序只支持 iOS 7+,解压缩包之后,择无法再获得对应的图片素材。
请妥善保管好一些优秀作品的 IPA 文件
图标素材 & App 名称
图标素材
设置图标选项
- 如下图所示,删除
Launch Screen File
&Main.storyboard
,并且设置启动图片
和应用方向
提示:iPhone 项目一般不需要支持横屏,游戏除外
添加图标
App 名称
- 提示
- 此处修改的内容是
Info.plist
中CFBundleName
对应的内容 - 注意不要超过6个中文,否则会影响用户体验
- 此处修改的内容是
启动程序
- 在
AppDelegate
的didFinishLaunchingWithOptions
函数中添加以下代码:
window = UIWindow(frame: UIScreen.mainScreen().bounds)window?.backgroundColor = UIColor.whiteColor()window?.rootViewController = ViewController()window?.makeKeyAndVisible()
运行测试
添加启动图片
- 提示
- 关于启动图片的设置,需要注意上课的操作细节
- 关于各个设备的实际屏幕尺寸,注意一下不同类型的启动图片即可
项目搭建
课程目标
- 熟悉 swift 语法
- 搭建系统主体框架结构
- 对比与 OC 开发的异同
- 纯代码搭建框架
创建文件
准备工作
删除模板文件
- ViewController.swift
- Main.storyboard
- LaunchScreen.xib
创建项目结构
主目录 Classes
二级目录
目录名 | 说明 |
---|---|
Module | 功能模块 |
Model | 业务逻辑模型 |
Tools | 工具类 |
Module
子目录
目录名 | 说明 |
---|---|
Main | 主要 |
Home | 首页 |
Message | 消息 |
Discover | 发现 |
Profile | 我 |
创建项目文件
Main
目录 | Controller |
---|---|
Main | MainViewController.swift(:UITabBarController ) |
功能模块
目录 | Controller |
---|---|
Home | HomeTableViewController.swift |
Message | MessageTableViewController.swift |
Discover | DiscoverTableViewController.swift |
Profile | ProfileTableViewController.swift |
细节
- 每个 ViewController 继承自
UITableViewController
- 搭建完成的文件结构图如下:
- 修改
AppDelegate
中的didFinishLaunchingWithOptions
函数,设置启动控制器
window?.rootViewController = MainViewController()
添加子控制器
功能需求
- 由于采用了多视图控制器的设计方式,因此需要通过代码的方式向主控制器中添加子控制器
文件准备
- 将素材文件夹中的
TabBar
拖拽到Images.xcassets
目录下
代码实现
添加第一个视图控制器
override func viewDidLoad() { super.viewDidLoad() addChildViewController()}private func addChildViewController() { tabBar.tintColor = UIColor.orangeColor() let vc = HomeTableViewController() vc.title = "首页" vc.tabBarItem.image = UIImage(named: "tabbar_home") let nav = UINavigationController(rootViewController: vc) addChildViewController(nav)}
重构代码抽取参数
/// 添加控制器////// - parameter vc : 视图控制器/// - parameter title : 标题/// - parameter imageName: 图像名称private func addChildViewController(vc: UIViewController, title: String, imageName: String) { tabBar.tintColor = UIColor.orangeColor() let vc = HomeTableViewController() vc.title = title vc.tabBarItem.image = UIImage(named: imageName) let nav = UINavigationController(rootViewController: vc) addChildViewController(nav)}
- 扩充调用函数,添加其他控制器
/// 添加所有子控制器private func addChildViewControllers() { addChildViewController(HomeTableViewController(), title: "首页", imageName: "tabbar_home") addChildViewController(MessageTableViewController(), title: "消息", imageName: "tabbar_message_center") addChildViewController(DiscoverTableViewController(), title: "发现", imageName: "tabbar_discover") addChildViewController(ProfileTableViewController(), title: "我", imageName: "tabbar_profile")}
自定义 TabBar
功能需求
- 在 4 个控制器切换按钮中间增加一个撰写按钮
- 点击撰写按钮能够弹出对话框撰写微博
需求分析
- 自定义 TabBar
- 计算控制器按钮位置,在中间添加一个
撰写
按钮
思路
- 加号按钮的大小与其他
tabBarItem
的大小是一致的 - 如果不考虑 modal 的方式,其所在位置应该同样有一个
tabBarItem
- 建立一个空的视图控制器形成占位
- 然后在该位置添加一个按钮遮挡
代码实现
- 添加空的视图控制器
/// 添加所有子控制器private func addChildViewControllers() { // ... addChildViewController(UIViewController()) // ...}
注意 UIViewController() 的位置
- 添加按钮
// MARK: - 懒加载/// 撰写按钮private lazy var composedButton: UIButton = { let btn = UIButton() btn.setImage(UIImage(named: "tabbar_compose_icon_add"), forState: UIControlState.Normal) btn.setImage(UIImage(named: "tabbar_compose_icon_add_highlighted"), forState: UIControlState.Highlighted) btn.setBackgroundImage(UIImage(named: "tabbar_compose_button"), forState: UIControlState.Normal) btn.setBackgroundImage(UIImage(named: "tabbar_compose_button_highlighted"), forState: UIControlState.Highlighted) self.tabBar.addSubview(btn) return btn}()
- 设置按钮位置
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() setupComposeButton()}/// 设置撰写按钮位置private func setupComposeButton() { let w = tabBar.bounds.width / CGFloat(childViewControllers.count) let rect = CGRect(x: 0, y: 0, width: w, height: tabBar.bounds.height) composedButton.frame = CGRectOffset(rect, 2 * w, 0)}
- 添加按钮监听方法
btn.addTarget(self, action: "clickComposeButton", forControlEvents: UIControlEvents.TouchUpInside)
- 按钮监听方法
/// 点击撰写按钮func clickComposeButton() { print(__FUNCTION__)}
注意:按钮的监听方法不能使用
private
阶段性小结
- 整体开发思路与使用 OC 几乎一致
- Swift 语法更加简洁
- Swift 对类型校验更加严格,不同类型的变量不允许直接计算
let w = tabBar.bounds.width / CGFloat(childViewControllers.count)
Swift 中的懒加载本质上是一个闭包,因此引用当前控制器的对象时需要使用 self.
不希望暴露的方法,应该使用
private
修饰符- 按钮点击事件的调用是由
运行循环
监听并且以消息机制
传递的,因此,按钮监听函数不能设置为private
第三方框架
项目中使用到以下第三方框架
AFNetworking
SDWebImage
SVProgressHUD
Pod 安装
- git 备份
- 打开终端
$ cd
进入项目目录- 输入以下终端命令建立或编辑
Podfile
$ vim Podfile
- 输入以下内容
use_frameworks!platform :ios, '8.0'pod 'AFNetworking'pod 'SDWebImage'pod 'SVProgressHUD'
:wq
保存退出输入以下命令安装第三方框架
$ pod install
- 如果第三方框架不能正常工作或者升级,可以输入以下命令更新
$ pod update
在 Swift 项目中,cocoapod 仅支持以 Framework 方式添加框架,因此需要在 Podfile 中添加
use_frameworks!
在终端提交添加的框架
# 将修改添加至暂存区$ git add .# 提交修改并且添加备注信息$ git commit -m "添加第三方框架"# 将修改推送到远程服务器$ git push
修改项目版本
AFNetworking
- 建立
NetworkTools
单例
import AFNetworking/// 网络工具类class NetworkTools: AFHTTPSessionManager { // 全局访问点 static let sharedNetworkTools: NetworkTools = { let instance = NetworkTools(baseURL: NSURL(string: "https://api.weibo.com/")!) return instance }()}
SDWebImage & SVProgressHUD
SVProgressHUD
SVProgressHUD
是使用 OC 开发的指示器- 使用非常广泛
框架地址
与 MBProgressHUD
对比
SVProgressHUD
- 只支持
ARC
- 支持较新的苹果 API
- 提供有素材包
- 使用更简单
- 只支持
MBProgressHUD
- 支持
ARC
&MRC
- 没有素材包,程序员需要针对框架进行一定的定制才能使用
- 支持
使用
import SVProgressHUDSVProgressHUD.showInfoWithStatus("正在玩命加载中...", maskType: SVProgressHUDMaskType.Gradient)
SDWebImage
import SDWebImagelet url = NSURL(string: "http://img0.bdstatic.com/img/image/6446027056db8afa73b23eaf953dadde1410240902.jpg")!SDWebImageManager.sharedManager().downloadImageWithURL(url, options: SDWebImageOptions.allZeros, progress: nil) { (image, _, _, _, _) in let data = UIImagePNGRepresentation(image) data.writeToFile("/Users/liufan/Desktop/123.jpg", atomically: true)}
单例
单例的目标
- 内存中只有一个对象实例
- 提供一个全局访问点
OC 中的单例
+ (instancetype)sharedManager { static id instance; static dispatch_once_t onceToken; NSLog(@"%ld", onceToken); dispatch_once(&onceToken, ^{ instance = [[self alloc] init]; }); return instance;}
Swift 中的单例
static var instance: NetworkTools?static var token: dispatch_once_t = 0/// 在 swift 中类变量不能是存储型变量class func sharedSoundTools() -> SoundTools { dispatch_once(&token) { () -> Void in instance = SoundTools() } return instance!}
不过!在 Swift 中
let
本身就是线程安全的
- 改进过的单例代码
private static let instance = NetworkTools()/// 在 swift 中类变量不能是存储型变量class var sharedNetworkTools: NetworkTools { return instance}
- 单例其实还可以更简单
static let sharedSoundTools = SoundTools()
OAuth
基本概念
- OAuth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准
- OAuth 的授权不会使第三方触及到用户的帐号信息
- OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据
- 每一个令牌授权一个
特定的网站
在特定的时段内
访问特定的资源
OAuth 授权流程图
注册应用程序
注册应用程序
- 注册新浪微博账号
- 访问
- 点击
微连接
-移动应用
- 填写基本信息,如下图所示:
- 点击
应用信息
-高级信息
,设置回调地址,如下图所示:
应用程序信息
Key | 值 |
---|---|
client_id | 113773579 |
client_secret | a34f52ecaad5571bfed41e6df78299f6 |
redirect_uri | |
access_token | 2.00ml8IrF0jh4hHe09f471dc4C_L3nC |
注意:授权回调地址一定要完全一致
加载授权页面
功能需求
- 通过浏览器访问新浪授权页面,获取授权码
接口文档
- 测试授权 URL
注意:回调地址必须与注册应用程序保持一致
功能实现
准备工作
- 新建
OAuth
文件夹 - 新建
OAuthViewController.swift
继承自UIViewController
加载 OAuth 视图控制器
- 修改
BaseTableViewController
中用户登录部分代码
/// 用户登录func visitorLoginViewWillLogin() { let nav = UINavigationController(rootViewController: OAuthViewController()) presentViewController(nav, animated: true, completion: nil)}
- 在
OAuthViewController
中添加以下代码
lazy var webView: UIWebView = { return UIWebView()}()override func loadView() { view = webView title = "新浪微博" navigationItem.rightBarButtonItem = UIBarButtonItem(title: "关闭", style: UIBarButtonItemStyle.Plain, target: self, action: "close")}/// 关闭func close() { dismissViewControllerAnimated(true, completion: nil)}
运行测试
加载授权页面
- 在
NetworkTools
中定义应用程序授权相关信息
// MARK: - 应用程序信息private var clientId = "113773579"private var clientSecret = "a34f52ecaad5571bfed41e6df78299f6"var redirectUri = "http://www.baidu.com"/// 授权 URLvar oauthURL: NSURL { return NSURL(string: "https://api.weibo.com/oauth2/authorize?client_id=\(clientId)&redirect_uri=\(redirectUri)")!}
- 在
info.plist
中增加ATS
设置
NSAppTransportSecurity NSAllowsArbitraryLoads
- 加载授权页面
override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) webView.loadRequest(NSURLRequest(URL: NetworkTools.sharedNetworkTools.oauthURL))}
- 实现代理方法,跟踪重定向 URL
// MARK: - UIWebView 代理方法func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool { print(request) return true}
结果分析
- 如果 URL 以回调地址开始,需要检查查询参数
- 其他 URL 均加载
修改代码
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool { // 判断请求的 URL 中是否包含回调地址 let urlString = request.URL!.absoluteString if !urlString.hasPrefix(NetworkTools.sharedNetworkTools.redirectUri) { return true } guard let query = request.URL?.query where query.hasPrefix("code=") else { print("取消授权") close() return false } let code = query.substringFromIndex(advance(query.startIndex, "code=".characters.count)) print("授权成功 \(code)") NetworkTools.sharedNetworkTools.loadAccessToken(code) return false}
加载指示器
- 导入
SVProgressHUD
import SVProgressHUD
- WebView 代理方法
func webViewDidStartLoad(webView: UIWebView) { SVProgressHUD.show()}func webViewDidFinishLoad(webView: UIWebView) { SVProgressHUD.dismiss()}
- 关闭
/// 关闭func close() { SVProgressHUD.dismiss() dismissViewControllerAnimated(true, completion: nil)}
AccessToken
课程目标
- 自定义对象
- 构造函数
- 归档 & 接档
接口定义
文档地址
接口地址
HTTP 请求方式
- POST
请求参数
参数 | 描述 |
---|---|
client_id | 申请应用时分配的AppKey |
client_secret | 申请应用时分配的AppSecret |
grant_type | 请求的类型,填写 authorization_code |
code | 调用authorize获得的code值 |
redirect_uri | 回调地址,需需与注册应用里的回调地址一致 |
返回数据
返回值字段 | 字段说明 |
---|---|
access_token | 用于调用access_token,接口获取授权后的access token |
expires_in | access_token的生命周期,单位是秒数 |
remind_in | access_token的生命周期(该参数即将废弃,开发者请使用expires_in) |
uid | 当前授权用户的UID |
UserAccount 模型
加载 AccessToken
- 在
NetworkTools
中增加函数加载AccessToken
/// 使用 code 获取 accessToken////// - parameter code: 请求码func loadAccessToken(code: String) { let urlString = "https://api.weibo.com/oauth2/access_token" let parames = ["client_id": clientId, "client_secret": clientSecret, "grant_type": "authorization_code", "code": code, "redirect_uri": redirectUri] POST(urlString, parameters: parames, success: { (_, JSON) -> Void in print(JSON) }) { (_, error) -> Void in print(error) }}
- 在
OAuthViewController
中获取授权码成功后调用网络方法
NetworkTools.sharedNetworkTools.loadAccessToken(code)
运行测试
- 返回错误信息
Error Domain=com.alamofire.error.serialization.response Code=-1016 "Request failed: unacceptable content-type: text/plain"
- 在
NetworkTools
中增加反序列化数据格式
// 设置反序列化数据格式集合instance.responseSerializer.acceptableContentTypes = NSSet(objects: "application/json", "text/json", "text/javascript", "text/plain") as Set
- 增加闭包回调
/// 使用 code 获取 accessToken////// - parameter code: 请求码func loadAccessToken(code: String, finished: (result: [String: AnyObject]?, error: NSError?)->()) { let urlString = "https://api.weibo.com/oauth2/access_token" let parames = ["client_id": clientId, "client_secret": clientSecret, "grant_type": "authorization_code", "code": code, "redirect_uri": redirectUri] POST(urlString, parameters: parames, success: { (_, JSON) in finished(result: JSON as? [String: AnyObject], error: nil) }) { (_, error) in finished(result: nil, error: error) }}
- 修改调用代码
private func loadAccessToken(code: String) { NetworkTools.sharedNetworkTools.loadAccessToken(code) { (result, error) -> () in if error != nil result == nil { SVProgressHUD.showInfoWithStatus("网络不给力") dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * Int64(NSEC_PER_SEC)), dispatch_get_main_queue()) { self.close() } return } print(result) }}
定义 UserAcount 模型
- 在
Model
目录下添加UserAccount
类 - 定义模型属性
/// 用于调用access_token,接口获取授权后的access tokenvar access_token: String?/// access_token的生命周期,单位是秒数var expires_in: String?/// 当前授权用户的UIDvar uid: String?init(dict: [String: AnyObject]) { super.init() self.setValuesForKeysWithDictionary(dict)}override func setValue(value: AnyObject?, forUndefinedKey key: String) {}
- 字典转模型
let account = UserAccount(dict: result!)print(account)
- 运行测试程序会崩溃!
因为从新浪服务器返回的
expires_in
是整数而不是字符串
- 调整代码,验证
expires_in
数据类型
responseSerializer = AFHTTPResponseSerializer()POST(urlString, parameters: parames, success: { (_, JSON) in print(NSString(data: JSON as! NSData, encoding: NSUTF8StringEncoding)) finished(result: JSON as? [String: AnyObject], error: nil) }) { (_, error) in finished(result: nil, error: error)}
再次运行测试
调试模型信息
与 OC 不同,如果要在 Swift 1.2 中调试模型信息,需要遵守
Printable
协议,并且重写description
的getter
方法,在 Swift 2.0 中,description
属性定义在CustomStringConvertible
协议中
override var description: String { let dict = ["access_token", "expires_in", "uid"] return "\(dictionaryWithValuesForKeys(dict))"}
目前的版本需要先遵守
CustomStringConvertible
协议,重写了description
属性后,再删除,相信后续版本中会得到改进
设置过期日期
过期日期
在新浪微博返回的数据中,过期日期是以当前系统时间加上秒数计算的,为了方便后续使用,增加过期日期属性
定义属性
/// token过期日期var expiresDate: NSDate?
- 修改构造函数
expiresDate = NSDate(timeIntervalSinceNow: expires_in)
- 修改
description
let properties = ["access_token", "expires_in", "expiresDate", "uid"]
归档 & 解档
课程目标
- 对比 OC 的
归档 & 解档
实现 利用
归档 & 解档
保存用户信息遵守协议
class UserAccount: NSObject, NSCoding
- 实现协议方法
// MARK: - NSCodingfunc encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(access_token, forKey: "access_token") aCoder.encodeDouble(expires_in, forKey: "expires_in") aCoder.encodeObject(expiresDate, forKey: "expiresDate") aCoder.encodeObject(uid, forKey: "uid")}required init?(coder aDecoder: NSCoder) { access_token = aDecoder.decodeObjectForKey("access_token") as? String expires_in = aDecoder.decodeDoubleForKey("expires_in") expiresDate = aDecoder.decodeObjectForKey("expiresDate") as? NSDate uid = aDecoder.decodeObjectForKey("uid") as? String}
- 定义归档路径
/// 归档保存路径private static let accountPath = NSSearchPathForDirectoriesInDomains(NSSearchPathDirectory.DocumentDirectory, NSSearchPathDomainMask.UserDomainMask, true).last!.stringByAppendingPathComponent("account.plist")
- 保存账户信息
/// 保存账号func saveAccount() { NSKeyedArchiver.archiveRootObject(self, toFile: UserAccount.accountPath)}
- 加载账户信息
/// 加载账号class func loadAccount() -> UserAccount? { let account = NSKeyedUnarchiver.unarchiveObjectWithFile(accountPath) as? UserAccount return account}
- 调整
OAuthViewController.swift
中的loadAccessToken
函数
// 保存用户账号信息UserAccount(dict: result!).saveAccount()
- 修改加载账号函数
/// 用户账号private static var userAccount: UserAccount?/// 加载账号class func loadAccount() -> UserAccount? { if userAccount == nil { // 解档用户账户信息 userAccount = NSKeyedUnarchiver.unarchiveObjectWithFile(accountPath) as? UserAccount } // 如果用户账户存在,判断是否过期 if let date = userAccount?.expiresDate where date.compare(NSDate()) == NSComparisonResult.OrderedAscending { userAccount = nil } return userAccount}
由于后续所有网络访问都基于用户账户中的
access_token
,因此定义一个全局变量,可以避免重复加载,而且能够在每次调用 AccessToken 时都判断是否过期
- 修改 BaseTableViewController 中的用户是否登录判断
/// 用户登录标记var userLogon = UserAccount.loadAccount() != nil
加载用户信息
课程目标
- 通过
AccessToken
获取新浪微博网络数据
接口定义
文档地址
接口地址
HTTP 请求方式
- GET
请求参数
参数 | 描述 |
---|---|
access_token | 采用OAuth授权方式为必填参数,其他授权方式不需要此参数,OAuth授权后获得 |
uid | 需要查询的用户ID |
返回数据
返回值字段 | 字段说明 |
---|---|
name | 友好显示名称 |
avatar_large | 用户头像地址(大图),180×180像素 |
测试 URL
代码实现
- 在
NetworkTools
中封装 GET 方法
/// 错误域private let errorDomainName = "com.itheima.network.errorDomain"// MARK: - 封装网络请求方法/// 完成回调类型typealias HMFinishedCallBack = (result: [String: AnyObject]?, error: NSError?) -> ()/// GET 请求////// - parameter urlString: URL 地址/// - parameter params : 参数字典/// - parameter finished : 完成回调private func requestGET(urlString: String, params: [String: AnyObject], finished: HMFinishedCallBack) { GET(urlString, parameters: params, success: { _, JSON in if let result = JSON as? [String: AnyObject] { finished(result: result, error: nil) } else { finished(result: nil, error: NSError(domain: errorDomainName, code: -10000, userInfo: ["error": "空数据"])) } }) { _, error in finished(result: nil, error: error) }}
- 定义通知常量
/// AccessToken 不存在通知let HMAccessTokenEmptyNotification = "HMAccessTokenEmptyNotification"
- 生成 Token 参数字典
/// 生成 Token 参数字典private func tokenDict() -> [String: AnyObject]? { if let token = UserAccount.loadAccount()?.access_token { return ["access_token": token] } NSNotificationCenter.defaultCenter().postNotificationName(HMAccessTokenEmptyNotification, object: nil) return nil}
- 在
NetworkTools
中增加加载用户信息函数
// MARK: - 加载用户信息func loadUserInfo(uid: Int, finished: (result: [String: AnyObject]?, error: NSError?) -> ()) { let urlString = "2/users/show.json" guard var params = tokenDict() else { return } params["uid"] = uid requestGET(urlString, params: params) { (result, error) -> () in finished(result: result, error: error) }}
- 在
UserAccount
中增加加载用户信息函数
func loadUserInfo() { NetworkTools.sharedTools.loadUserInfo(uid!) { (result, error) -> () in print(result) }}
- 测试加载用户信息
UserAccount(dict: result!).loadUserInfo()
- 增加属性定义
/// 友好显示名称var name: String?/// 用户头像地址(大图),180×180像素var avatar_large: String?
- 调整加载用户信息函数
// MARK: - 加载用户信息func loadUserInfo(finished: (error: NSError?) -> ()) { NetworkTools.sharedTools.loadUserInfo(uid!) { (result, error) -> () in if let dict = result { self.name = dict["name"] as? String self.avatar_large = dict["avatar_large"] as? String self.saveAccount() } finished(error: error) }}
- 修改
description
属性
let properties = ["access_token", "expires_in", "uid", "expiresDate", "name", "avatar_large"]
- 修改归档&解档函数,增加用户名和图像地址属性
func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(access_token, forKey: "access_token") aCoder.encodeDouble(expires_in, forKey: "expires_in") aCoder.encodeObject(expiresDate, forKey: "expiresDate") aCoder.encodeObject(uid, forKey: "uid") aCoder.encodeObject(name, forKey: "name") aCoder.encodeObject(avatar_large, forKey: "avatar_large")}required init?(coder aDecoder: NSCoder) { access_token = aDecoder.decodeObjectForKey("access_token") as? String expires_in = aDecoder.decodeDoubleForKey("expires_in") expiresDate = aDecoder.decodeObjectForKey("expiresDate") as? NSDate uid = aDecoder.decodeObjectForKey("uid") as? String name = aDecoder.decodeObjectForKey("name") as? String avatar_large = aDecoder.decodeObjectForKey("avatar_large") as? String}
- 修改
loadAccessToken
方法
/// 使用授权码换取 AccessTokenprivate func loadAccessToken(code: String) { NetworkTools.sharedTools.loadAccessToken(code) { (result, error) -> () in if error != nil || result == nil { self.loadError() return } // 加载用户账号信息 UserAccount(dict: result!).loadUserInfo() { (error) -> () in if error != nil { self.loadError() return } print(UserAccount.loadAccount()) } }}/// 数据加载错误private func loadError() { SVProgressHUD.showInfoWithStatus("您的网络不给力") // 延时一段时间再关闭 let when = dispatch_time(DISPATCH_TIME_NOW, Int64(1 * NSEC_PER_SEC)) dispatch_after(when, dispatch_get_main_queue()) { self.close() }}
每一个令牌授权一个
特定的网站
在特定的时段内
访问特定的资源
调整网络代码
- 封装 POST 请求方法
/// POST 请求////// - parameter urlString: URL 地址/// - parameter params : 参数字典/// - parameter finished : 完成回调private func requestPOST(urlString: String, params: [String: AnyObject], finished: HMFinishedCallBack) { POST(urlString, parameters: params, success: { _, JSON in if let result = JSON as? [String: AnyObject] { finished(result: result, error: nil) } else { finished(result: nil, error: NSError(domain: errorDomainName, code: -10000, userInfo: ["error": "空数据"])) } }) { _, error in print(error) finished(result: nil, error: error) }}
- 修改加载 token 函数
/// 加载 Tokenfunc loadAccessToken(code: String, finished: HMFinishedCallBack) { let urlString = "https://api.weibo.com/oauth2/access_token" let params = ["client_id": clientId, "client_secret": appSecret, "grant_type": "authorization_code", "code": code, "redirect_uri": redirectUri] requestPOST(urlString, params: params) { (result, error) -> () in finished(result: result, error: error) }}
新特性
- 新特性是现在很多应用程序中包含的功能,主要用于在系统升级后,用户第一次进入系统时获知新升级的功能
课程目标
- UICollectionView 使用
根视图控制器
切换
新特性功能
准备文件
- 将新特性图片素材拖拽到 Images.xcsets 中
- 在
Module
下建立NewFeature
目录 - 新建
NewFeatureViewController.swift
继承自UICollectionViewController
- 在
NewFeatureViewController.swift
的末尾添加如下代码:
代码实现
- 修改
AppDelegate
的根视图控制器
window?.rootViewController = NewFeatureViewController()
运行测试,崩溃!
原因:实例化
CollectionViewController
时必须指定布局参数实现
init()
简化外部调用
/// 界面布局private let layout = UICollectionViewFlowLayout()init() { super.init(collectionViewLayout: layout)}required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented")}
- 定义 NewFeatureCell
/// 新特性 Cellclass NewFeatureCell: UICollectionViewCell { var imageIndex: Int = 0 { didSet { iconView.image = UIImage(named: "new_feature_\(imageIndex + 1)") } } override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(iconView) // 自动布局 // 1> 图片视图 iconView.translatesAutoresizingMaskIntoConstraints = false contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-0-[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": iconView])) contentView.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": iconView])) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // 懒加载控件 lazy var iconView: UIImageView = UIImageView()}
- 注册可重用 Cell
override func viewDidLoad() { super.viewDidLoad() // 注册可重用 Cell self.collectionView!.registerClass(NewFeatureCell.self, forCellWithReuseIdentifier: reuseIdentifier)}
运行测试,需要设置布局属性
- 设置布局属性
/// 新特性布局private class NewFeatureLayout: UICollectionViewFlowLayout { private override func prepareLayout() { itemSize = collectionView!.bounds.size minimumInteritemSpacing = 0 minimumLineSpacing = 0 scrollDirection = UICollectionViewScrollDirection.Horizontal collectionView?.pagingEnabled = true collectionView?.showsHorizontalScrollIndicator = false collectionView?.bounces = false }}
在
prepareLayout
函数中定义 collectionView 的布局属性是最佳位置
- 修改布局属性
/// 界面布局private let layout = NewFeatureLayout()
- 定义按钮
/// 按钮lazy var startButton: UIButton = { let button = UIButton() button.setBackgroundImage(UIImage(named: "new_feature_finish_button"), forState: UIControlState.Normal) button.setBackgroundImage(UIImage(named: "new_feature_finish_button_highlighted"), forState: UIControlState.Highlighted) button.setTitle("开始体验", forState: UIControlState.Normal) return button}()
- 设置按钮布局
// 2> 开始按钮startButton.translatesAutoresizingMaskIntoConstraints = falsecontentView.addConstraint(NSLayoutConstraint(item: startButton, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: contentView, attribute: NSLayoutAttribute.CenterX, multiplier: 1.0, constant: 0))contentView.addConstraint(NSLayoutConstraint(item: startButton, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: contentView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: -160))
动画显示 开始体验
按钮
- 在
NewFeatureCell
中添加showStartButton
函数
/// 动画显示按钮func showStartButton() { startButton.hidden = false startButton.transform = CGAffineTransformMakeScale(0, 0) startButton.userInteractionEnabled = false UIView.animateWithDuration(1.2, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 10.0, options: UIViewAnimationOptions(rawValue: 0), animations: { self.startButton.transform = CGAffineTransformIdentity }) { _ in self.startButton.userInteractionEnabled = true }}
- 在
collectionView
的完成显示Cell
代理方法中添加以下代码:
// 参数 cell, indexPath 是前一个 cell 和 indexPathoverride func collectionView(collectionView: UICollectionView, didEndDisplayingCell cell: UICollectionViewCell, forItemAtIndexPath indexPath: NSIndexPath) { let indexPath = collectionView.indexPathsForVisibleItems().last! if indexPath.item == imageCount - 1 { (collectionView.cellForItemAtIndexPath(indexPath) as! NewFeatureCell).showStartButton() }}
注意:参数中的
cell
&indexPath
是之前消失的cell
,而不是当前显示的cell
的
隐藏状态栏
override func prefersStatusBarHidden() -> Bool { return true}
欢迎界面
- 在新浪微博中,如果用户登录成功会显示一个欢迎界面
- 特例:如果用户的系统刚刚升级或者第一次登录,会显示
新特性
界面,而不是欢迎
界面
准备文件
- 在
NewFeature
目录下新建WelcomeViewController.swift
继承自UIViewController
- 新建
Welcome.storyboard
,初始视图控制器的自定义类为WelcomeViewController
代码实现
- 修改
AppDelegate
的根视图控制器
window?.rootViewController = WelcomeViewController()
- 懒加载控件
// MARK: - 懒加载控件/// 背景图片private lazy var backImageView: UIImageView = UIImageView(image: UIImage(named: "ad_background"))/// 头像视图private lazy var iconView: UIImageView = { let iv = UIImageView(image: UIImage(named: "avatar_default_big")) iv.layer.masksToBounds = true iv.layer.cornerRadius = 45 return iv}()/// 文本标签private lazy var messageLabel: UILabel = { let label = UILabel() label.text = "欢迎归来" return label}()
- 搭建界面
/// 头像底部约束private var iconBottomCons: NSLayoutConstraint?override func viewDidLoad() { super.viewDidLoad() prepareUI()}/// 准备 UIprivate func prepareUI() { view.addSubview(backImageView) view.addSubview(iconView) view.addSubview(messageLabel) // 自动布局 // 1> 背景图片 backImageView.translatesAutoresizingMaskIntoConstraints = false view.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("H:|-0-[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": backImageView])) view.addConstraints(NSLayoutConstraint.constraintsWithVisualFormat("V:|-0-[subview]-0-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["subview": backImageView])) // 2> 头像 iconView.translatesAutoresizingMaskIntoConstraints = false view.addConstraint(NSLayoutConstraint(item: iconView, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: view, attribute: NSLayoutAttribute.CenterX, multiplier: 1.0, constant: 0)) view.addConstraint(NSLayoutConstraint(item: view, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: iconView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: 160)) iconBottomCons = view.constraints.last // 3> 标签 messageLabel.translatesAutoresizingMaskIntoConstraints = false view.addConstraint(NSLayoutConstraint(item: messageLabel, attribute: NSLayoutAttribute.CenterX, relatedBy: NSLayoutRelation.Equal, toItem: iconView, attribute: NSLayoutAttribute.CenterX, multiplier: 1.0, constant: 0)) view.addConstraint(NSLayoutConstraint(item: messageLabel, attribute: NSLayoutAttribute.Top, relatedBy: NSLayoutRelation.Equal, toItem: iconView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: 20))}
- 界面动画
override func viewDidAppear(animated: Bool) { super.viewDidAppear(animated) iconBottomCons?.constant = UIScreen.mainScreen().bounds.height - 240 UIView.animateWithDuration(1.2, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 10.0, options: UIViewAnimationOptions(rawValue: 0), animations: { self.view.layoutIfNeeded() }, completion: nil)}
参数说明
usingSpringWithDamping
的范围为0.0f
到1.0f
,数值越小弹簧
的振动效果越明显initialSpringVelocity
则表示初始的速度,数值越大一开始移动越快,初始速度取值较高而时间较短时,会出现反弹情况
设置用户头像
if let urlString = UserAccount.loadAccount()?.avatar_large { iconView.sd_setImageWithURL(NSURL(string: urlString)!)}
- 添加图像宽高约束
view.addConstraint(NSLayoutConstraint(item: iconView, attribute: NSLayoutAttribute.Height, relatedBy: NSLayoutRelation.Equal, toItem: nil, attribute: NSLayoutAttribute.NotAnAttribute, multiplier: 1.0, constant: 90))view.addConstraint(NSLayoutConstraint(item: view, attribute: NSLayoutAttribute.Bottom, relatedBy: NSLayoutRelation.Equal, toItem: iconView, attribute: NSLayoutAttribute.Bottom, multiplier: 1.0, constant: 160))
代码评审(Code Review)
通常在企业开发中,会定期
面对面
(face to face)对代码进行评审
Code Review的意识
- 作为一个
Developer
,不仅要提交可工作的代码
(Deliver working code),更要提交可维护的代码
(Deliver maintainable code) - 必要时进行重构,随着项目的迭代,在计划新增功能的同时,开发要主动计划重构的工作项
- 开放的心态,虚心接受大家的
评审建议
(Review Comments)
代码评审的方式
- 开 Code Review 会议
- 团队内部会整理 Check List
- 团队内部成员交换代码
- 找出可优化方案
- 多问问题,例如:“这块儿是怎么工作的?”、“如果有XXX 情况,你这个怎么处理?”
- 区分重点,优先抓住
设计
,可读性
,健壮性
等重点问题 - 整理好的编码实践,用来作为
Code Review
的参考
评审内容
架构/设计
- 单一职责原则
- 这是经常被违背的原则。一个类只能干一个事情,一个方法最好也只干一件事情。比较常见的违背是
一个类既干UI的事情,又干逻辑的事情
,这个在低质量的客户端代码里很常见
- 这是经常被违背的原则。一个类只能干一个事情,一个方法最好也只干一件事情。比较常见的违背是
- 行为是否统一,例如:
- 缓存是否统一
- 错误处理是否统一
- 错误提示是否统一
- 弹出框是否统一
- ……
- 代码污染
- 代码有没有对其他模块强耦合
- 重复代码
- 开闭原则
- 面向接口编程
- 健壮性
- 是否考虑线程安全
- 数据访问是否一致性
- 边界处理是否完整
- 逻辑是否健壮
- 是否有内存泄漏
- 有没有循环依赖
- 有没有野指针
- ……
- 错误处理
- 改动是不是对代码的提升
- 新的改动是打补丁,让代码质量继续恶化,还是对代码质量做了修复
- 效率/性能
- 关键算法的时间复杂度多少?有没有可能有潜在的性能瓶颈
- 客户端程序对频繁消息和较大数据等耗时操作是否处理得当
代码风格
- 可读性
- 衡量可读性的可以有很好实践的标准,就是 Reviewer 能否非常容易的理解这个代码。如果不是,那意味着代码的可读性要进行改进
- 命名
- 命名对可读性非常重要
- 英语用词尽量准确一点,必要时可以查字典
- 函数长度/类长度
- 函数太长的不好阅读
- 类太长了,检查是否违反的
单一职责
原则
- 注释
- 恰到好处的注释
- 参数个数
- 不要太多,一般不要超过 3 个
Review Your Own Code First
- 每次提交前整体把自己的代码过一遍非常有帮助,尤其是看看有没有犯低级错误
OAuthViewController
- 删除多余的 print
- 删除 // TODO: 换取 TOKEN
- 修改
loadAccessToken
函数中的注释
提示:在实际开发中,代码中的注释一定要及时调整!
UserAccount
知识点:类属性
vs 类函数
- 都是
通过类名调用
- 类属性作为属性一定有返回值
- 类函数不一定有返回值
- 类本质上只是对
对象
的描述,从面相对象的角度而言,类不应该有存储功能- 类属性是只读的,可以返回一个函数计算结果
- 也可以返回一个私有静态成员记录的内容
- 通过类属性,能够提高代码的可读性
演练 & 体会
- 将
loadAccount()
类函数修改为sharedUserAccount
类属性
class var sharedUserAccount: UserAccount? { // 1. 判断账户是否存在 if userAccount == nil { // 解档 - 如果没有保存过,解档结果可能仍然是 nil userAccount = NSKeyedUnarchiver.unarchiveObjectWithFile(accountPath) as? UserAccount } // 2. 判断日期 if let date = userAccount?.expiresDate where date.compare(NSDate()) == NSComparisonResult.OrderedAscending { // 如果已经过期,需要清空账号记录 userAccount = nil } return userAccount}
- 利用编译器提示修改出错的代码
对比前后两种方式的代码可读性的提高
- 说明:类属性是 Swift 特有的语法,仅供体会
NetworkTools
- 移动
HMNetFinishedCallBack
声明的位置
定义网络访问错误枚举
- 定义网络访问错误枚举
/// 网络访问错误private enum HMNetworkError: Int { case emptyDataError = -1 case emptyTokenError = -2 private var description: String { switch self { case .emptyDataError: return "空数据" case .emptyTokenError: return "AccessToken 错误" } } private var error: NSError { return NSError(domain: HMErrorDomainName, code: rawValue, userInfo: [HMErrorDomainName: description]) }}
可以在 Playground 中测试枚举类型
- 修改
requestGET
中的空数据错误
finished(result: nil, error: HMNetworkError.emptyDataError.error)
- 修改
loadUserInfo
中 token 为空的检测代码,增加错误回调
// 判断 token 是否存在if UserAccount.sharedUserAccount?.access_token == nil { let error = HMNetworkError.emptyTokenError.error print(error) finished(result: nil, error: error) return}
- 注释
UserAccount
中为全局账号赋值的代码,并且调试运行效果
封装 AFN 的 POST 方法
- 复制 GET 代码,并且修改部分单词
/// POST 请求////// :param: urlString URL 地址/// :param: params 参数字典/// :param: finished 完成回调private func requestPOST(urlString: String, params: [String: AnyObject], finished: HMNetFinishedCallBack) { POST(urlString, parameters: params, success: { (_, JSON) -> Void in if let result = JSON as? [String: AnyObject] { // 有结果的回调 finished(result: result, error: nil) } else { // 没有错误,同时没有结果 print("没有数据 GET Request \(urlString)") finished(result: nil, error: HMNetworkError.emptyDataError.error) } }) { (_, error) -> Void in print(error) finished(result: nil, error: error) }}
- 修改 函数并运行测试
/// 加载 Tokenfunc loadAccessToken(code: String, finished: HMNetFinishedCallBack) { let urlString = "https://api.weibo.com/oauth2/access_token" let params = ["client_id": clientId, "client_secret": appSecret, "grant_type": "authorization_code", "code": code, "redirect_uri": redirectUri] requestPOST(urlString, params: params, finished: finished)}
整合网络访问方法
- 定义网络方法枚举
/// 网络访问方法private enum HMNetworkMethod: String { case GET = "GET" case POST = "POST"}
- 封装网络访问方法
/// 网络请求////// - parameter method : 访问方法/// - parameter urlString: URL 地址/// - parameter params : 参数自带呢/// - parameter finished : 完成回调private func request(method: HMNetworkMethod, urlString: String, params: [String: AnyObject], finished: HMNetFinishedCallBack) { let successCallBack: (NSURLSessionTask!, AnyObject!) -> Void = { _, JSON in if let result = JSON as? [String: AnyObject] { // 有结果的回调 finished(result: result, error: nil) } else { // 没有错误,同时没有结果 print("没有数据 \(method) Request \(urlString)") finished(result: nil, error: HMNetworkError.emptyDataError.error) } } let failedCallBack: (NSURLSessionTask!, NSError!) -> Void = { _, error in print(error) finished(result: nil, error: error) } switch method { case .GET: GET(urlString, parameters: params, success: successCallBack, failure: failedCallBack) case .POST: POST(urlString, parameters: params, success: successCallBack, failure: failedCallBack) }}
运行测试
自动布局框架
- 为简化纯代码布局,抽取了常用的自动布局代码
将 UIView+AutoLayout 拖拽到项目中的
Tools
目录下调整
NewFeatureCell
iconView.ff_Fill(contentView)startButton.ff_AlignInner(type: ff_AlignType.BottomCenter, referView: contentView, size: nil, offset: CGPoint(x: 0, y: -160))
- 调整
WelcomeViewController
// 1> 背景图片backImageView.ff_Fill(view)// 2> 头像let cons = iconView.ff_AlignInner(type: ff_AlignType.BottomCenter, referView: view, size: CGSize(width: 90, height: 90), offset: CGPoint(x: 0, y: -160))// 记录底边约束iconBottomCons = iconView.ff_Constraint(cons, attribute: NSLayoutAttribute.Bottom)// 3> 标签label.ff_AlignVertical(type: ff_AlignType.BottomCenter, referView: iconView, size: nil, offset: CGPoint(x: 0, y: 16))
- 修改动画方法中的约束数值
iconBottomCons?.constant = -UIScreen.mainScreen().bounds.height - iconBottomCons!.constant