微博开发笔记上(未完待续)
2015-09-04 23:43
316 查看
新浪微博开发笔记
iPhone 项目目标
项目掌控能力工具使用能力
开发技巧能力
课程提纲
新浪微博接口地址
微博开放平台地址http://open.weibo.com
微博接口文档地址
http://open.weibo.com/wiki/微博API
项目主题框架
走向工作岗位之后,一般会遇到两种工作情况:新项目开发
通常在项目开始之前,公司的产品经理会提供完整的产品原型图,或功能设计文档
通过对这些文档的解读,能够梳理出目标项目的整体架构,从而协助项目框架的搭建
旧项目维护
很多老项目是缺乏文档的,这种情况在一些小公司中表现的尤为突出
要想快速上手一个老项目,首先运行项目,并且整理项目整体框架结构
然后用整理出的框架结构与代码结构相互印证,无疑可以对了解项目的整体架构起到重要的辅助
综上所述,无论是新项目,还是老项目,在开发之前确定项目的主体架构都是非常重要,也是十分必要的!
主体架构确认的好处
开发之前,明确项目的主体架构具有以下好处:明确开发目标,项目一旦启动,始终锁定目标前进!
明确功能模块的数量,方便工期核算
根据开发进度,预判开发周期,及时与相关部门沟通、协调
根据主体架构搭建项目框架,方便团队开发,各个功能模块齐头并进,提高开发效率!
确定项目开发中的重点难点,提前安排攻关能力强的同事进行技术攻关,待需要时能够享受攻关成果,或者及时调整产品设计
新增或调整功能时,能够高屋建瓴,在最合适的位置添加相关功能模块
新浪微博
作为中国移动互联网的代表性产品之一,新浪微博涵盖了大量的移动互联网元素,通过对新浪微博的研究及模仿,可以:对这些元素在实际产品中的应用有深入的了解和认识
知道如何在一个真实的项目中运用相关技术点
对大型项目的架构、开发及掌控有更全面的认识和理解
正如前文所述,在开始模仿之前,首先运行产品,掌握项目的整体架构,确定开发的主体功能非常重要!
新浪微博主体架构
对界面预览之后,可以发现新浪微博符合经典应用程序架构设计:主视图控制器是一个
UITabbarController
包含四个
UINavigationController,分别是
首页
消息
发现
我
特殊之处:
-
UITabbarController中间有一个 “+” 按钮,点击该按钮能够 Modal 显示
微博类型选择界面,方便用户选择自己需要的微博类型
- 四个
UINavigationController在用户登录前后显示的界面格式是不一样的
根原版新浪微博的区别
由于必须使用新浪微博官方的 API 才能够正常开发,换言之,如果没有登录系统是无法使用新浪微博提供的接口的!基于上述原因,在实际开发中对未登录之前的界面设计进行简化
开源中国社区
官方网站
https://git.oschina.net/开源中国社区成立于2008年8月,其目的是为中国的IT技术人员提供一个全面的、快捷更新的用来检索开源软件以及交流使用开源经验的平台
目前国内有很多公司会将公司的项目部署在
OSChina
与 GitHUB
的对比
服务器在国内,速度更快免费账户同样可以建立
私有项目,而
GitHUB上要建立私有项目必须
付费
使用
注册账号建议使用网易的邮箱,使用其他免费邮箱可能会收不到验证邮件
添加 SSH 公钥,进入终端,并输入以下命令
开源中国帮助文档地址:https://git.oschina.net/oschina/git-osc/wikis/帮助#ssh-keys
# 切换目录,MAC中目录的第一个字符如果是 `.` 表示改文件夹是隐藏文件夹 $ cd ~/.ssh # 查看当前目录文件 $ ls # 生成 RSA 密钥对 # 1> "" 中输入个人邮箱 # 2> 提示输入私钥文件名称,直接回车 # 3> 提示输入密码,可以随便输入,只要本次能够记住即可 $ ssh-keygen -t rsa -C "xxx@126.com" # 查看公钥内容 $ cat id_rsa.pub
将公钥内容复制并粘贴至 https://git.oschina.net/profile/sshkeys
测试公钥
# 测试 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 4 iPhone 4s iPhone 5 iPhone 5s iPhone 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.swiftMain.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 开发的指示器
使用非常广泛
框架地址
https://github.com/TransitApp/SVProgressHUD与 MBProgressHUD
对比
SVProgressHUD
只支持
ARC
支持较新的苹果 API
提供有素材包
使用更简单
MBProgressHUD
支持
ARC&
MRC
没有素材包,程序员需要针对框架进行一定的定制才能使用
使用
import SVProgressHUD SVProgressHUD.showInfoWithStatus("正在玩命加载中...", maskType: SVProgressHUDMaskType.Gradient)
SDWebImage
import SDWebImage let 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 授权流程图
注册应用程序
注册应用程序
注册新浪微博账号访问 http://open.weibo.com
点击
微连接-
移动应用
填写基本信息,如下图所示:
点击
应用信息-
高级信息,设置回调地址,如下图所示:
应用程序信息
Key | 值 |
---|---|
client_id | 113773579 |
client_secret | a34f52ecaad5571bfed41e6df78299f6 |
redirect_uri | http://www.baidu.com |
access_token | 2.00ml8IrF0jh4hHe09f471dc4C_L3nC |
加载授权页面
功能需求
通过浏览器访问新浪授权页面,获取授权码接口文档
http://open.weibo.com/wiki/Oauth2/authorize测试授权 URL
https://api.weibo.com/oauth2/authorize?client_id=479651210&redirect_uri=http://itheima.com
注意:回调地址必须与注册应用程序保持一致
功能实现
准备工作
新建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" /// 授权 URL var oauthURL: NSURL { return NSURL(string: "https://api.weibo.com/oauth2/authorize?client_id=\(clientId)&redirect_uri=\(redirectUri)")! }
在
info.plist中增加
ATS设置
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>
加载授权页面
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://open.weibo.com/wiki/OAuth2/access_token接口地址
https://api.weibo.com/oauth2/access_tokenHTTP 请求方式
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<NSObject>
增加闭包回调
/// 使用 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 token var access_token: String? /// access_token的生命周期,单位是秒数 var expires_in: String? /// 当前授权用户的UID var 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: - NSCoding 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") } 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://open.weibo.com/wiki/2/users/show接口地址
https://api.weibo.com/2/users/show.jsonHTTP 请求方式
GET请求参数
参数 | 描述 |
---|---|
access_token | 采用OAuth授权方式为必填参数,其他授权方式不需要此参数,OAuth授权后获得 |
uid | 需要查询的用户ID |
返回数据
返回值字段 | 字段说明 |
---|---|
name | 友好显示名称 |
avatar_large | 用户头像地址(大图),180×180像素 |
测试 URL
https://api.weibo.com/2/users/show.json?access_token=2.00ml8IrF0qLZ9W5bc20850c50w9hi9&uid=5365823342代码实现
在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方法
/// 使用授权码换取 AccessToken private 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 函数
/// 加载 Token func 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
/// 新特性 Cell class 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 = false contentView.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 和 indexPath override 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() } /// 准备 UI private 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) } }
修改 函数并运行测试
/// 加载 Token func 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
相关文章推荐
- SGU 194. Reactor Cooling【无源汇上下界最大流】
- C#之Attribute(特性)
- Atitit. 衡量项目规模 ----包含的类的数量 .net java类库包含多少类 多少个api方法??
- Common Lisp 学习笔记(1)
- 排序算法总结
- Atitit. 衡量项目规模 ----包含的类的数量 .net java类库包含多少类 多少个api方法??
- Atitit. 衡量项目规模 ----包含的类的数量 .net java类库包含多少类 多少个api方法??
- 文本编辑工具vim的使用方法练习
- iOS开发-用ZipArchive添加和解压zip包
- apache+tomcat反向代理
- mysql SQL Select 语句 简单应用
- GraphX的基本介绍
- VS2015+OpenCV3.0配置教程
- 杭电OJ-2093_考试排名
- 简单文本函数
- vncserver的自动启动及vncserver桌面的自动准备
- Xcode默认快捷键太别扭,一招教你制服它!
- poj 3624
- 程序包管理之rpm/yum/编译
- Linux命令汇总之正则表达式