データ一覧の取得
データ一覧画面では、登録した収支の一覧表示と、収支の編集処理を行うことができます。このページでは、データの一覧の実装方法を説明します。編集処理は次のページで説明します。
クラス構成
データ一覧画面は、以下のクラス図のような構成を取ります。中心となるのは、UIViewController
クラスを継承した BalanceListViewController
クラスです。同時に、UITableViewDelegate
プロトコルと UITableViewDataSource
のプロトコルを実装しているため、Table View の機能も持っています。
BalanceListViewController
クラスは Kii Cloud にアクセスし、収支の一覧データを KiiObject
の配列として取得します。取得した結果は、BalanceListViewController
のインスタンス変数で保持します。
また、Table View の各セルとして、TotalAmountTableViewCell
クラスと BalanceItemTableViewCell
クラスを用意します。これら 2 つは、セル内のフィールドをアウトレットとして保持するだけのシンプルなクラスです。
ソースコードは以下のとおりです。
Swift
Objective-C
- BalanceListViewController.h / BalanceListViewController.m
- BalanceItemTableViewCell.h / BalanceItemTableViewCell.m
- TotalAmountTableViewCell.h / TotalAmountTableViewCell.m
データ一覧の取得
データ一覧画面では、収支のデータが格納された 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:)
メソッドによって取得できます()。検索結果は、コールバックの
results
引数でKiiObject
の配列として取得できます。まだ次のページがあるため、nextQuery
に次のページを取得するためのクエリーが格納されています。 -
2 ページ目
次のページは、1 ページ目の検索結果と同時に得られた
nextQuery
を使って、execute(_:with:)
メソッドを実行すると取得できます()。同様に、検索結果は
results
で取得できます。さらに次のページがあるため、nextQuery
に次のページを取得するためのクエリーが格納されています。 -
3 ページ目
次のページは、2 ページ目の検索結果と同時に得られた
nextQuery
を使って、execute(_:with:)
メソッドを実行すると取得できます()。ここで、
nextQuery
はnil
であるため、次のページは存在しません。全件の取得が完了したことを表します。
ページネーションの実装を以下に示します。このコードは、上記のコードのクエリーの実行部分です。
-
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
を参照できるため、再帰処理を実装できます。
- Swift ではクロージャの再帰呼び出しで実現します。Swift の文法では、クロージャを保持する変数の定義中にその変数を参照できません。上記のコードのように、いったん
-
KiiProgress
クラスにより、進捗表示を行っています。進捗表示を終えるタイミングは、エラーの発生により次のページ以降を処理しないとき、または、全件の処理が完了したときです。 - エラーからの回復処理のため、取得した
KiiObject
はself.items
に直接反映しません。取得結果はobjectList
にいったん保存され、全件の処理が完了したタイミングでまとめて反映されます。もし、self.items
を直接更新するようにした実装で 2 ページ目がエラーとなると、エラー発生後は 1 ページ目だけが残り続けます。
最後に示したエラーからの回復処理は、実際のモバイルアプリで品質を確保する上で重要なテーマとなります。Kii Cloud の API の実行にはネットワークアクセスが必要なため、スマートフォンの電波状況などの不安定な要素が API のエラーに直結します。
モバイルアプリでは、単純にエラーメッセージを表示するだけでなく、エラー発生後にユーザーがどのように回復するかを考慮しておく必要があります。こうしたシナリオの考慮が不十分だと、データの不整合が発生したり、モバイルアプリの操作手段がなくなったりする不具合につながります。なお、ここでのエラーケースは、query
の limit
プロパティでページサイズを小さく設定し、デバッガのブレークポイントと端末の機内モードを組み合わせるなどしてテストできます。
一覧の取得に成功したときは、取得した KiiObject
を self.items
に登録し、BalanceListViewController
の管理下に入れます。同時に、ナビゲーションバーのリフレッシュボタンをデータ追加ボタンに置き換えます。失敗したときは、この置き換えが行われないため、リフレッシュボタンを利用して再取得を行う想定です。
Kii Balance では、ページネーションをノンブロッキング API で実装しましたが、Grand Central Dispatch(GCD)とブロッキング API を使うと実装がシンプルになる場合があります。
コールバックのメモリリーク対策
ページネーションの再帰処理では、クロージャ(KiiQueryResultBlock
)の循環参照が発生するため、メモリリークの対策が必要です。
クエリーの実行の際には、様々なオブジェクトへの参照を扱っていますが、ここでは BalanceListViewController
インスタンス、getItems()
メソッド、クロージャの 3 つに着目します。以下に示すように、これらはいずれも強参照で保持されています。
ここで、参照 では循環参照が発生しているため、そのままではメモリリークが発生します。
クロージャの実装では、再帰呼び出しの実現のため、callback
を使用しています。この値は強参照としてクロージャにキャプチャされるため、自分自身への強参照のループとなっています。
Kii Balance では、エラーの発生後、または、全件取得の完了後に、callback = nil
によって参照を削除することで、循環参照を解消しています。
なお、図中の残りの 2 つの参照では循環参照が発生していないため、そのままでもメモリリークは発生しません。
-
参照
これは、
getItems()
メソッド内の参照です。クロージャへの強参照をローカル変数callback
が持っています。getItems()
メソッドの終了とともに、callback
自体は消えるため、メモリリークは発生しません。 -
参照
これは、クロージャ内部での参照です。クロージャの処理で
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
クラスを使って収支を表示するセルを作成します。
次は...
データ一覧画面でのデータ項目の編集機能について説明します。
データ項目の編集機能 に移動してください。