大数据

The iOS Apprentice : Checklists – 学习笔记

iOS Apprentice系列是“Ray”团队撰写的教程,内容非常好,但是由于全篇是英文的,所以看的小伙伴们应该不是很多。正因为这样,笔者决定抽出个人时间来研究这系列教程并写出总结,以博客的形式呈献给大家。

iOS Apprentice教程地址:The iOS Apprentice Fourth Edition

本篇总结是继上一篇The iOS Apprentice : Getting Started – 学习笔记总结的iOS Apprentice教程的下一篇总结,总结的教程是iOS Apprentice系列基于Objective-C的第二篇:Checklists:

iOS Apprentice : Checklists

该教程以开发一个To-do list app的形式教给读者iOS开发中比较基础,但同时又比较重要的知识点:UITableView,代理模式,数组,数据持久化和app生命周期等。

Checklists 效果图 | 图片来自 iOS Apprentice Checklists 教程

笔者总结了这篇教程,但是并没有详细记录开发该app的每一个步骤,而是将重点的知识点抽取出来提炼而成。

本总结的前半部分讲解了一些大块知识点,后面单独将零碎的知识点抽取出来放到了一起。因为笔者认为将零碎知识点脱离出来有助于消化大块知识,不容易扰乱读者的思路。因此,本总结的叙述顺序是和原教程是有所出入的。

开始吧~

UITableView


1. Reuse Identifier

将cell赋予标识符(reuse identifier)的目的有两个:

  • 便于TableView在重用cell时,在“重用池”中取出具有特定标识符的cell。
  • 区分其他类型的cell。因为即使是在同一个tableview里,也可能有设计不同的cell:有的有图,有的无图。所以,就需要通过标识符来区别不同形式的cell。

cell的重用代码:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ChecklistItem"]; 
    return cell;
}

2. NSIndexPath

NSIndexPath类有两个用于指向特定section,特定row的cell。如果将tableview比喻为街区,cell必须为该街区里的建筑,那么indexPath就是某建筑在该街区“门牌号”,也就是地址。

NSIndexPath的初始化:

NSIndexPath *indexPath=[NSIndexPath indexPathForRow:3 inSection:0];

而且,我们可以通过cell实例来获得它的“地址”:

NSIndexPath *indexPath = [self.tableView  indexPathForCell :cell];

3. UITableViewDataSource 和 UITableViewDelegate

有些时候,我们需要让一些对象专心处理自己擅长的工作,而把其他的工作“委托”,或“外包”给其他对象来做。用UITableview举个例子:

UITableView通过UITableViewDataSource来获取数据,通过UITableViewDelegate来处理点击事件。

UITableViewDataSource需要实现数据源方法,例如:

@required
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section;//某个section有几个row
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;//返回某一行的cell

@optional
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView; //有几个section

UITableViewDelegate需要实现点击后的相应方法,例如:

@optional
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;//点击了某一行
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;//设置行高

4. 区分cell和row

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath

{

    UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];

    if (cell.accessoryType == UITableViewCellAccessoryNone) { 

        cell.accessoryType = UITableViewCellAccessoryCheckmark;

    } else {
        cell.accessoryType = UITableViewCellAccessoryNone;

    }

    [tableView deselectRowAtIndexPath:indexPath animated:YES]; 

}

在这里,我们将accessoryType依赖于cell:如果当前cell没有checkmark,就显示,否则不显示。这样做一定会出现问题:因为cell会被重用。如果重用的cell的accessoryType属性和应该显示出来的accessoryType不一致,就会造成bug。因此,正确的做法应该是让一个“模型”对象来专门负责某一特定cell的accessoryType属性。

5. 通过模型来显示cell的view

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    //1. 取出当前的cell
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ChecklistItem"];

    //2. 取出当前的cell对应的模型
    ChecklistItem *item = _items[indexPath.row];

    //3. 配置text
    [self configureTextForCell:cell withChecklistItem:item];

    //4. 配置checkmark
    [self configureCheckmarkForCell:cell withChecklistItem:item];

    return cell;
}

/**
 *  设置checkmark
 *
 *  @param cell 当前cell对象
 *  @param item 当前cell的模型
 */
- (void)configureCheckmarkForCell:(UITableViewCell *)cell withChecklistItem:(ChecklistItem *)item
{
  if (item.checked) {
    cell.accessoryType = UITableViewCellAccessoryCheckmark;
  } else {
    cell.accessoryType = UITableViewCellAccessoryNone;
  }
}


/**
 *  设置text
 *
 *  @param cell 当前的cell
 *  @param item 当前cell的模型
 */
- (void)configureTextForCell:(UITableViewCell *)cell withChecklistItem:(ChecklistItem *)item
{
  UILabel *label = (UILabel *)[cell viewWithTag:1000];
  label.text = item.text;
}

这里的configureTextForCell:withChecklistItem:configureCheckmarkForCell:withChecklistItem: ,都是通过view所属的模型来更新自己。这两个方法的实现都没有写在tableView:cellForRowAtIndexPath:方法里,而是封装到了各自的方法里,简洁易懂。

6. 将模型的变化方法封装在模型里

在点击cell后,它的checkmark的显示属性会改变。这个时候,我们不应该用控制器去引用模型来更改其状态,而是向模型自己发送方法,更改其状态。

- (void)toggleChecked
{
      self.checked = !self.checked;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
      UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];


      ChecklistItem *item = _items[indexPath.row];
      [item toggleChecked];//让模型自己更新状态
      [self configureCheckmarkForCell:cell withChecklistItem:item];


      [tableView deselectRowAtIndexPath:indexPath animated:YES];
}

7. cell删除行和添加行的操作:

//添加行
- (IBAction)addItem
{

    NSInteger newRowIndex = [_items count];
    ChecklistItem *item = [[ChecklistItem alloc] init];

    item.text = @"I am a new row";

    item.checked = NO;
    [_items addObject:item];//模型同步

    NSIndexPath *indexPath = [NSIndexPath indexPathForRow:newRowIndex inSection:0];
    NSArray *indexPaths = @[indexPath];

    //在指定indexPath添加行(可以是多行)
    [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
}




//删除行
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath
{
    [_items removeObjectAtIndex:indexPath.row];//模型同步
    NSArray *indexPaths = @[indexPath];

    //删除指定indexPath的行(可以是多行)
    [tableView deleteRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationAutomatic];
}

在这里我们可以看到:

  1. 无论是添加,删除行,都可以进行多行操作。而且,即使是只操作单行,我们仍然要将其放入数组里。

  2. 无论是添加,删除行,都意味着模型的变化,我们应该及时更新模型,以免造成view和模型的不同步,引起崩溃。

不可变类型


1. NSArray

NSArray是不可变的,它在被创造的那一刻后就无法再添加或者删除元素了,只能访问它包含的元素。但是!我们却可以改变某一特定的元素。

就好比我们排队,队形虽然是固定的,但是排队的人可以做不同的事情:看手机,看书,发愣。。。

注意,当你向可变数组发送removeObjectAtIndex:方法后,不仅会将元素移除数组,同样也会销毁它。因为当元素处于数组中时,数组会有一个强指针指向它。当这个元素不属于这个数组后,该指针就会消失。除非还有其他强引用,该元素就会因为没有强引用而被释放掉。

2. NSString

同样,NSString在被创造出来之后,也是不能被更改的,只能创造一个新的字符串并指向它。

举个例子, [string lowercase] 并不会在原来的string里作任何操作,而是重新生成一个全小写字母的string。

UITextField


让键盘在页面出现时就已经弹出

我们需要将呼出键盘的方法在viewWillAppear:中调用。该方法是在页面显示出的前一刻被调用。

- (void)viewWillAppear:(BOOL)animated 
{
    [super viewWillAppear:animated];
    [self.textField becomeFirstResponder]; 
}

得到输入文字的内容和位置

- (BOOL)textField:(UITextField *)theTextField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
    NSString *newText = [theTextField.text stringByReplacingCharactersInRange:rangewithString:string];

    self.doneBarButton.enabled = ([newText length] > 0);

    return YES; 
}

在这里, shouldChangeCharactersInRange delegate 并不会给我们处于输入框里最新的文本,而是只告诉我们要替换的文本和其所在的范围。所以我们如果要获取输入框里的最新文本,还需我们在这个方法里手动生成.

界面跳转之前的准备工作


如果两个VC是在故事板里用连线连接而成的,那么我们通常会在prepareForSegue:sender:方法中做一些跳转之前的准备工作。

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{ 
    if ([segue.identifier isEqualToString:@"AddItem"]) {

        // 1
        UINavigationController *navigationController = segue.destinationViewController;

        // 2
        AddItemViewController *controller = (AddItemViewController *)navigationController.topViewController; 

        controller.delegate = self;
    } 
}

在这里,首先使用segue.identifier来判断跳转的路径是否正确。
导航控制器的topViewController属性是指向位于栈顶,也就是显示在当前屏幕上的控制器。

比较对象的相等性


在iOS里,双等号是用来比较指针的相等性的:如果两个指针指向同一对象,那么在用双等号作比较时,就是对的。如果两个指针指向不同的对象,那么就返回错。

然而,我们都知道,即使是两个指针指向的内存不同,这两块内存包含的内容也可以是相同的。如下图:

对象的相等性 | 图片来自 iOS Apprentice 教程

举个简单的例子,两个小男孩可能都叫做Tom,但他们却是两个不同的人。如果我们只是比较名字,那么就是正确的;但是如果我们比较的是人,那么显然是错的。

数据持久化


有时,app需要保留一些数据在本地,便于数据的快速读取等需求,而且这些数据在app退到后台,甚至是终止运行仍要需要保留,这就需要我们使用数据持久化技术。

数据持久化 | 图片来自iOS Apprentice教程

所有的iOS app都有自己的Documents文件夹,我们可以把文件备份在这里,同时,它还可以被同步到iTunes和iCloud。因此,即使在将来的某一时刻,用户将app升级了,在这个路径里的文件是不受到任何影响的。

Documents文件夹是存在于沙盒目录下的。那么什么是沙盒目录呢?

沙盒是用来保护app的,每个app都有属于自己的“沙盒”,将自己的数据放在这个沙盒里面。各个app之间井水不犯河水,不会读取,更不会更改其他app的数据的原因正是其受到沙盒的保护。

先看一下数据持久化的其中一种方法:plist

plist文件的持久化


我们可以将数据写入plist里,并将plist保存在沙盒中,来达到数据持久化的目的。

具体步骤如下:

  1. 获取plist文件的路径。
  2. 向plist文件里写入数据。

笔者就本Demo来讲一下如何将数组写入plist:

首先,我们需要在Documents文件夹里新创建一个plist文件:
第一步:获取plist文件的路径:

-  (NSString *)documentsDirectory {
   NSArray *paths = NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES);
   NSString *documentsDirectory = [paths firstObject];
   return documentsDirectory;
}



-  (NSString *)dataFilePath {
      return [[self documentsDirectory] stringByAppendingPathComponent:@"Checklists.plist"];
}

注意:这里的stringByAppendingPathComponent:不同于stringByAppendingString;,它会自动生成表示目录层级的斜杠。相对来说更方便一些。

调用dataFilePath后,我们就能获取该plist的路径。

第二步:将数组写入文件

- (void)saveChecklistItems {

    NSMutableData *data = [[NSMutableData alloc] init];

    NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data]; 
    [archiver encodeObject:_items forKey:@"ChecklistItems"];
    [archiver finishEncoding];
    [data writeToFile:[self dataFilePath] atomically:YES]; 
}

在这里,我们将一个数组文件写入到了plist文件里,也就是将OC对象转化为了NSData进行保存。像这样的转化过程,是需要条件的:我们需要让数组里的所有对象遵从协议,并重写initWithCoder:的方法,并将该对象的所有属性编码。

数组内部的对象ChecklistItem遵从协议:

@interface ChecklistItem: NSObject 

重写encodeWithCoder:方法:

- (id)initWithCoder:(NSCoder *)aDecoder {

     if ((self = [super init])) {
     self.text = [aDecoder decodeObjectForKey:@"Text"]; 
     self.checked = [aDecoder decodeBoolForKey:@"Checked"];
    }    
    return self; 
}

对属性编码:

- (void)encodeWithCoder:(NSCoder *)aCoder {
    [aCoder encodeObject:self.text forKey:@"Text"]; 
    [aCoder encodeBool:self.checked forKey:@"Checked"]; 
}

那么反过来,我们可以将现有的plist转化为数组:

- (void)loadChecklistItems
{
          NSString *path = [self dataFilePath];

          if ([[NSFileManager defaultManager] fileExistsAtPath:path]) {
                NSData *data = [[NSData alloc] initWithContentsOfFile:path];
                NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
                _items = [unarchiver decodeObjectForKey:@"ChecklistItems"];    
                [unarchiver finishDecoding];
          } else {
             _items = [[NSMutableArray alloc] initWithCapacity:20];
          }
}

在这里,使用了NSFileManagerfileExistsAtPath:方法来判断特定路径下是否文件存在:如果存在plist,就将其转化为数组;如果不存在,就新建一个数组。

其实,我们并没有必要在模型每次改变时都要对属性列表进行一次读写操作,因为数据持久化是针对于app停止运行,或前后台切换的,因此,我们只需要在这些情况下对数据进行持久化保存即可。既然说到了前后台的切换,就引入了下一个主题:应用程序的声明周期。

应用程序的生命周期:


讲解应用程序声明周期的方法很多博客里已经有很详细的讲解了。在这里只结合当前的Demo来讲解其中几个重要的声明周期函数。

// In Appdelegate.m:
//程序已经启动:初始化模型


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    _dataModel = [[DataModel alloc] init];

    UINavigationController *navigationController = (UINavigationController *)self.window.rootViewController;

    AllListsViewController *controller = navigationController.viewControllers[0];
    controller.dataModel = _dataModel; return YES;
} 


//程序已经进入后台:保存数据

-  (void)applicationDidEnterBackground: (UIApplication *)application
{
    [self saveData];
}



//程序即将终止运行:保存数据

-  (void)applicationWillTerminate:(UIApplication *)application 
{
    [self saveData];
} 

- (void)saveData 
{
  UINavigationController *navigationController = (UINavigationController *)self.window.rootViewController;

    AllListsViewController *controller = navigationController.viewControllers[0];
    [controller saveChecklists]; 
}

讲完了属性列表和附带讲解的应用程序声明周期,现在来看一下第二种:属性列表

NSUserDefaults


除了将数据保存在plist文件中,我们还可以使用iOS框架中的属性列表:NSUserDefaults。NSUserDefaults提供一个单例,可以让我们像给字典赋值一样来将数据存储在沙盒中。

我们来看一下它的具体使用:

设值:记录点击的行数

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    [[NSUserDefaults standardUserDefaults] setInteger:indexPath.row forKey:@"ChecklistIndex"];

    Checklist *checklist = self.dataModel.lists[indexPath.row];
    [self performSegueWithIdentifier:@"ShowChecklist" sender:checklist]; 

}

注意:

在这里,通过[NSUserDefaults standardUserDefaults]来获取NSUserDefaults的单例。并且,根据存入值的类型来改变设值得方法。

这里的indexPath.row是整数,所以使用setInteger:forKey:的方法。

取值:获取行数

- (void)viewDidAppear:(BOOL)animated {

    [super viewDidAppear:animated];

    self.navigationController.delegate = self;
    NSInteger index = [[NSUserDefaults standardUserDefaults] integerForKey:@"ChecklistIndex"];

    if (index != -1) {
        Checklist *checklist = self.dataModel.lists[index];
        [self performSegueWithIdentifier:@"ShowChecklist" sender:checklist];
    } 
}

同样地,在这里。我们也要根据获取数据的类型来改变相应的获取方法:integerForKey

本地通知:


启用本地通知可以起到提醒用户的效果,它可以在某个时间点来提醒用户:“现在应该做(看)什么了”。例如:该睡觉了,该吃药了,到了该做*的时间了等等。

本地通知 | 图片来自 iOS Apprentice 教程

我们来看一下本地通知的使用步骤:

  1. 首先定义一个本地通知
  2. 然后在收到本地通知的方法里使用通知的信息来进行各种操作。

第一步:自定义一个本地通知:

NSDate *date = [NSDate dateWithTimeIntervalSinceNow:10];

UILocalNotification *localNotification =[[UILocalNotification alloc] init];
localNotification.fireDate = date; localNotification.timeZone = [NSTimeZone defaultTimeZone]; localNotification.alertBody = @"I am a local notification!"; 
localNotification.soundName =UILocalNotificationDefaultSoundName;

[[UIApplication sharedApplication] scheduleLocalNotification:localNotification];

第二步:收到本地通知

//AppDelegate.m
- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification
{
    NSLog(@"didReceiveLocalNotification %@", notification);
}

注意:app在前台运行时 是无法收到本地通知的!只有在app在后台运行或者完全关闭的情况下才会收到本地通知!

本教程的大块知识点已经总结完毕,下面来看一下本教程的零散知识点:

零散知识点


1. 通过Tag来获取TableviewCell里的子view

UILabel *label = (UILabel *)[cell viewWithTag:1000];

2. 让模态视图消失的方法

[self.presentingViewController dismissViewControllerAnimated:YES completion:nil];

在这里的presentingViewController是指“呈现”该模态视图的VC,也就是说该消息并不是发给模态视图的。这样看来,每个模态视图都有一个presentingViewController属性,来指向呈现自己的控制器。

3. 如何获取storyboard

viewController.storyboard

每个控制器都有一个stroyboard属性来指向加载自己的故事版。

4. 代理方法的保护机制

   if (self.delegate){
        [self.delegate doSomething]; 
   }

在给代理发送消息时,应该首先判断代理是否存在,也就是说有没有其他对象是自己的代理。
但是这个判断并不严谨,还应该判断那个代理是否实现了代理方法:

if ([self.delegate respondsToSelector:@selector(doSomething)]) {
            [self.delegate doSomething]; ;
 }

5. 两种容器类控制器

导航控制器:可以拥有多个控制器,控制控制器之间的跳转
标签控制器:可以拥有多个控制器,控制控制器之间的切换

6. 设置应用程序的主色调

我们可以设置全局的主色调:

设置全局主色调 | 图片来自 iOS Apprentice 教程

设置了全局的主色调之后,就可以在任意位置获取它:

label.textColor = self.view.tintColor;

其中,tintColor就是程序的主色调。

最后的话


本篇教程还是比较基础的,而且作者讲解的浅显易懂,有很多细节上的东西都提到了,建议大家在读完本篇总结后,最好抽出时间来看一下原文。