データ一覧の取得

データ一覧画面では、登録した収支の一覧表示と、収支の編集処理を行うことができます。このページでは、データの一覧の実装方法を説明します。編集処理は次のページで説明します。

クラス構成

データ一覧画面は、以下のクラス図のような構成を取ります。中心となるのは、UIViewController クラスを継承した BalanceListViewController クラスです。同時に、UITableViewDelegate プロトコルと UITableViewDataSource のプロトコルを実装しているため、Table View の機能も持っています。

BalanceListViewController クラスは Kii Cloud にアクセスし、収支の一覧データを KiiObject の配列として取得します。取得した結果は、BalanceListViewController のインスタンス変数で保持します。

また、Table View の各セルとして、TotalAmountTableViewCell クラスと BalanceItemTableViewCell クラスを用意します。これら 2 つは、セル内のフィールドをアウトレットとして保持するだけのシンプルなクラスです。

ソースコードは以下のとおりです。

Swift

Objective-C

データ一覧の取得

データ一覧画面では、収支のデータが格納された KiiObject を、Kii Cloud から取得します。

Kii Cloud でのデータ取得には、主に以下の 2 通りの方法がありますが、今回は前者の検索機能を使用します。

  • 特定 Bucket 内の検索によって取得する方法

    指定した条件に一致する KiiObject をまとめて取得します。データ一覧画面では、この方法が適しています。

  • KiiObject の ID を指定して取得する方法

    過去に扱った特定の KiiObject の ID を記憶しておき、次回に取得し直す方法です。ID の管理が必要なため、データ一覧画面のケースには適していません。

データの取得処理は、viewDidLoad() メソッドを契機に実行します。このメソッドは、ビューが読み込まれたタイミングで呼び出されます。

実際の取得処理は、getItems() メソッドで以下のように実装されています。まずはクエリーの実行を行うまでの処理を示します。

  • override func viewDidLoad() {
      super.viewDidLoad()
      ......
      self.getItems()
    }
    
    func getItems() {
      let bucket = KiiUser.current()!.bucket(withName: BalanceItem.Bucket)
    
      // Create a query instance.
      let query = KiiQuery.init(clause: nil)
      // Sort KiiObjects by the _created field.
      query.sort(byAsc: BalanceItem.FieldCreated)
    
      let progress = KiiProgress.create(message:"Loading...")
      self.present(progress, animated: false, completion: nil)
    
      var allResults: [KiiObject] = []
    
      // Define a recursive closure to get all KiiObjects.
      var callback: KiiQueryResultBlock?
      callback = { (query: KiiQuery?, bucket: KiiBucket, results: [Any]?, nextQuery: KiiQuery?, error: Error?) -> Void in
        ......
      }
      bucket.execute(query, with: callback!)
    }
  • - (void)viewDidLoad {
      [super viewDidLoad];
      ......
      [self getItems];
    }
    
    - (void) getItems {
      KiiBucket *bucket = [[KiiUser currentUser] bucketWithName:BalanceItemBucket];
    
      // Create a query instance.
      KiiQuery *query = [KiiQuery queryWithClause:nil];
      // Sort KiiObjects by the _created field.
      [query sortByAsc:BalanceItemFieldCreated];
    
      UIAlertController *progress = [KiiProgress createWithMessage:@"Loading..."];
      [self presentViewController:progress animated:NO completion:nil];
    
      NSMutableArray *allResults = [[NSMutableArray alloc] init];
    
      // Define a recursive block to get all KiiObjects.
      __block KiiQueryResultBlock callback = ^(KiiQuery *query, KiiBucket *bucket, NSArray *results, KiiQuery *nextQuery, NSError *error) {
        ......
      };
      [bucket executeQuery:query withBlock:callback];
    }

ここでは、Bucket 内の KiiObject の全件を、作成日時昇順の条件でクエリーを実行しています。クエリーの実行方法は、Hello Kii と同じです。詳細は Hello Kii の解説 を参照してください。

なお、execute(_:with:) メソッドでクエリーを実行するとき、クロージャ(または Block)は引数に直接記述せず、変数 callback を使って指定しています。これは、次のセクションで説明するページネーションに対応するためですが、実質的な引数値は Hello Kii と同じです。

ページネーション

今回は、クエリーの実行結果に対して、ページネーションの処理を行って、より完全なデータ一覧処理を実現します。

ページネーションは、検索結果の数が多い場合、それらをページ単位に分割して取得する手法です。

たとえば、420 件ヒットした KiiObject を取得する場合、以下のように複数のページに分割して取得できます。

デフォルトでは、bestEffortLimit が 200 に設定されているため、最大 200 件のページに分けて取得できます。ただし、転送サイズなどの様々な条件により、2 回目の取得例のように、次のページがあるのに 1 回分の取得件数が 200 件に満たない場合もあります。また、最後のページでは、取得件数が 0 件になる場合もあります。

上の図で、各 API が返す値は以下のとおりです。

  • 1 ページ目

    初めのページは、KiiBucket クラスの execute(_:with:) メソッドによって取得できます(:num1:)。検索結果は、コールバックの results 引数で KiiObject の配列として取得できます。まだ次のページがあるため、nextQuery に次のページを取得するためのクエリーが格納されています。

  • 2 ページ目

    次のページは、1 ページ目の検索結果と同時に得られた nextQuery を使って、execute(_:with:) メソッドを実行すると取得できます(:num2:)。同様に、検索結果は results で取得できます。さらに次のページがあるため、nextQuery に次のページを取得するためのクエリーが格納されています。

  • 3 ページ目

    次のページは、2 ページ目の検索結果と同時に得られた nextQuery を使って、execute(_:with:) メソッドを実行すると取得できます(:num3:)。ここで、nextQuerynil であるため、次のページは存在しません。全件の取得が完了したことを表します。

ページネーションの実装を以下に示します。このコードは、上記のコードのクエリーの実行部分です。

  • func getItems() {
      ......
      let progress = KiiProgress.create(message:"Loading...")
      self.present(progress, animated: false, completion: nil)
    
      var objectList: [KiiObject] = []
    
      // Define a recursive closure to get all KiiObjects.
      var callback: KiiQueryResultBlock?
      callback = { (query: KiiQuery?, bucket: KiiBucket, results: [Any]?, nextQuery: KiiQuery?, error: Error?) -> Void in
        if error != nil {
          progress.dismiss(animated: true, completion: {
            let description = (error! as NSError).userInfo["description"] as! String
            let alert = KiiAlert.create(title: "Error", message: description)
            self.present(alert, animated: true, completion: nil)
          })
          callback = nil
          return
        }
        objectList.append(contentsOf: results as! [KiiObject])
    
        // Check if more KiiObjects exit.
        if nextQuery == nil {
          progress.dismiss(animated: true, completion: nil)
          self.navigationItem.rightBarButtonItem = self.addButton
          self.items = objectList
          self.tableView.reloadData()
          callback = nil
        } else {
          // Get the remaining KiiObjects.
          bucket.execute(nextQuery, with: callback!)
        }
      }
      // Call the Kii Cloud API to query KiiObjects.
      bucket.execute(query, with: callback!)
    }
  • - (void) getItems {
      ......
      UIAlertController *progress = [KiiProgress createWithMessage:@"Loading..."];
      [self presentViewController:progress animated:NO completion:nil];
    
      NSMutableArray *objectList = [[NSMutableArray alloc] init];
    
      // Define a recursive block to get all KiiObjects.
      __block KiiQueryResultBlock callback = ^(KiiQuery *query, KiiBucket *bucket, NSArray *results, KiiQuery *nextQuery, NSError *error) {
        if (error != nil) {
          [progress dismissViewControllerAnimated:YES completion:^{
            UIAlertController *alert = [KiiAlert createWithTitle:@"Error" andMessage:error.description];
            [self presentViewController:alert animated:YES completion:nil];
          }];
          callback = nil;
          return;
        }
        [objectList addObjectsFromArray:results];
    
        // Check if more KiiObjects exit.
        if (nextQuery == nil) {
          [progress dismissViewControllerAnimated:YES completion:nil];
          self.navigationItem.rightBarButtonItem = self.addButton;
          self.items = objectList;
          [self.tableView reloadData];
          callback = nil;
        } else {
          // Get the remaining KiiObjects.
          [bucket executeQuery:nextQuery withBlock:callback];
        }
      };
      // Call the Kii Cloud API to query KiiObjects.
      [bucket executeQuery:query withBlock:callback];
    }

この処理での着目点は以下のとおりです。

  • ページネーションは、KiiQueryResultBlock の再帰呼び出しによって実現しています。
    • Swift ではクロージャの再帰呼び出しで実現します。Swift の文法では、クロージャを保持する変数の定義中にその変数を参照できません。上記のコードのように、いったん var ステートメントで変数を宣言したあと、代入と参照を別の行で実行することによって再帰処理を実装できます。
    • Objective-C では、Block の再帰呼び出しで実現します。変数を __block キーワードとともに宣言すると、Block の定義中に、その変数 callback を参照できるため、再帰処理を実装できます。
  • KiiProgress クラスにより、進捗表示を行っています。進捗表示を終えるタイミングは、エラーの発生により次のページ以降を処理しないとき、または、全件の処理が完了したときです。
  • エラーからの回復処理のため、取得した KiiObjectself.items に直接反映しません。取得結果は objectList にいったん保存され、全件の処理が完了したタイミングでまとめて反映されます。もし、self.items を直接更新するようにした実装で 2 ページ目がエラーとなると、エラー発生後は 1 ページ目だけが残り続けます。

最後に示したエラーからの回復処理は、実際のモバイルアプリで品質を確保する上で重要なテーマとなります。Kii Cloud の API の実行にはネットワークアクセスが必要なため、スマートフォンの電波状況などの不安定な要素が API のエラーに直結します。

モバイルアプリでは、単純にエラーメッセージを表示するだけでなく、エラー発生後にユーザーがどのように回復するかを考慮しておく必要があります。こうしたシナリオの考慮が不十分だと、データの不整合が発生したり、モバイルアプリの操作手段がなくなったりする不具合につながります。なお、ここでのエラーケースは、querylimit プロパティでページサイズを小さく設定し、デバッガのブレークポイントと端末の機内モードを組み合わせるなどしてテストできます。

一覧の取得に成功したときは、取得した KiiObjectself.items に登録し、BalanceListViewController の管理下に入れます。同時に、ナビゲーションバーのリフレッシュボタンをデータ追加ボタンに置き換えます。失敗したときは、この置き換えが行われないため、リフレッシュボタンを利用して再取得を行う想定です。

Kii Balance では、ページネーションをノンブロッキング API で実装しましたが、Grand Central Dispatch(GCD)とブロッキング API を使うと実装がシンプルになる場合があります。

コールバックのメモリリーク対策

ページネーションの再帰処理では、クロージャ(KiiQueryResultBlock)の循環参照が発生するため、メモリリークの対策が必要です。

クエリーの実行の際には、様々なオブジェクトへの参照を扱っていますが、ここでは BalanceListViewController インスタンス、getItems() メソッド、クロージャの 3 つに着目します。以下に示すように、これらはいずれも強参照で保持されています。

ここで、参照 :labelc: では循環参照が発生しているため、そのままではメモリリークが発生します。

クロージャの実装では、再帰呼び出しの実現のため、callback を使用しています。この値は強参照としてクロージャにキャプチャされるため、自分自身への強参照のループとなっています。

Kii Balance では、エラーの発生後、または、全件取得の完了後に、callback = nil によって参照を削除することで、循環参照を解消しています。

なお、図中の残りの 2 つの参照では循環参照が発生していないため、そのままでもメモリリークは発生しません。

  • 参照 :labela:

    これは、getItems() メソッド内の参照です。クロージャへの強参照をローカル変数 callback が持っています。getItems() メソッドの終了とともに、callback 自体は消えるため、メモリリークは発生しません。

  • 参照 :labelb:

    これは、クロージャ内部での参照です。クロージャの処理で self を使用しているため、クロージャは BalanceListViewController インスタンスへの強参照を持っています。

    Web 上でクロージャから self の参照について調べると、循環参照を扱った技術情報が見つかりますが、今回の実装では循環参照が発生していない点にご注意ください。今回は BalanceListViewController のインスタンス変数としてクロージャへの参照を持っておらず、クロージャから BalanceListViewController への片方向の参照のみが存在しています。このケースでは、メモリリークは発生しません。

    なお、クロージャを BalanceListViewController のインスタンス変数として管理するような場合は、循環参照が発生するため、クロージャ内の self を弱参照で指定するように実装するのが妥当です。

Table View への表示

tableView(_:cellForRowAt:) メソッドでは、項目表示のためのセルを作成して返します。

Hello Kii の場合と同様、KiiObject インスタンスに格納されているデータを表示形式に加工し、Table View の仕様に従って 1 項目分のセルを返します。

Kii Balance での実装は以下のとおりです。

  • func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      if indexPath.section == 0 {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Total", for: indexPath) as! TotalAmountTableViewCell
        let total = self.calcTotal()
        if total < 0 {
          cell.amountLabel.textColor = UIColor.red
        } else {
          cell.amountLabel.textColor = UIColor.black
        }
        cell.amountLabel.text = self.formatCurrency(amount: total)
        return cell
      } else {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! BalanceItemTableViewCell
        let obj = self.items[indexPath.row]
        let name = obj.getForKey(BalanceItem.FieldName) as! String
        let type = (obj.getForKey(BalanceItem.FieldType) as! NSNumber).intValue
        let amount = (obj.getForKey(BalanceItem.FieldAmount) as! NSNumber).intValue
    
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        let date = formatter.string(from: obj.created!)
        cell.dateLabel.text = date
    
        var amountDisplay: Double
        cell.nameLabel.text = name
        if type == BalanceItemType.Income.rawValue {
          cell.amountLabel.textColor = UIColor.black
          amountDisplay = Double(amount) / 100.0
        } else {
          cell.amountLabel.textColor = UIColor.red
          amountDisplay = -1 * Double(amount) / 100.0
        }
        cell.amountLabel.text = self.formatCurrency(amount: amountDisplay)
        return cell;
      }
    }
  • - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
      if (indexPath.section == 0) {
        TotalAmountTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Total" forIndexPath:indexPath];
        double total = [self calcTotal];
        if (total < 0) {
          cell.amountLabel.textColor = [UIColor redColor];
        } else {
          cell.amountLabel.textColor = [UIColor blackColor];
        }
        cell.amountLabel.text = [self formatCurrency: total];
        return cell;
      } else {
        BalanceItemTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
        KiiObject *obj = self.items[indexPath.row];
        NSString *name = [obj getObjectForKey:BalanceItemFieldName];
        NSNumber *type = [obj getObjectForKey:BalanceItemFieldType];
        int amount = [(NSNumber*)[obj getObjectForKey:BalanceItemFieldAmount] doubleValue];
    
        NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
        formatter.dateStyle = NSDateFormatterMediumStyle;
        cell.dateLabel.text = [formatter stringFromDate:obj.created];
    
        double amountDisplay;
        cell.nameLabel.text = name;
        if ([type intValue] == BalanceItemTypeIncome) {
          cell.amountLabel.textColor = [UIColor blackColor];
          amountDisplay = amount / 100.0;
        } else {
          cell.amountLabel.textColor = [UIColor redColor];
          amountDisplay = -1 * amount / 100.0;
        }
        cell.amountLabel.text = [self formatCurrency: amountDisplay];
        return cell;
      }
    }

ここでは、以下のように KiiObject の各フィールドと表示項目を対応付けます。

Table View のセクション 0 は合計領域であるため、TotalAmountTableViewCell クラスを使って合計を表示するセルを作成します。セクション 1 は収支の表示領域であるため、BalanceItemTableViewCell クラスを使って収支を表示するセルを作成します。


次は...

データ一覧画面でのデータ項目の編集機能について説明します。

データ項目の編集機能 に移動してください。