UITableViewControllerでセルが重複して描画される問題

iPhoneアプリ設定画面に於ける表組み

iPhoneアプリ開発では慣れていないと凡そ見当の付かないトラブルに 出会でくわします。 開発中のゲームアプリでは 設定画面に表組みを利用しました が例えばHTMLでtableタグを用いるような極く常識的な感覚で実装していたら思わぬ陥穽に嵌まってしまいました。 何処にも繰り返しの命令など記述していないにも関わらず 実際にアプリを走らせてみると設定画面の表を縦に、上にスクロールして行くと 上で表示された筈の内容が再び下から現れるのでした。

最初は此の現象を前にして皆目意味が分かりませんでした。 ソースを試行錯誤して少しだけ変更するのを繰り返したりしましたが一向に改善は見られない処か 増す増す深みに嵌まってしまうのでか細い よすが ですが iOSプログラミングでは表組みの実装に使うクラス UITableViewController をキーワードにするなどしてネットを繰って漸く見付けたのがサイト ”たゆたえども沈まず”で の2013年3月28日の記事でした。

cellForRowAtIndexPath が呼ばれるタイミングとは?

参考記事に於いては以下引用の記述が目に止まります。

スポンサーリンク
日付:2014年3月21日
開発機:MacBook Air(11-inch, Mid 2013)
MacOSバージョン:OS X 10.9.2
Xcodeバージョン:5.1
言語:Objective-C
主関連アプリ:uPanda Breaks Out Fruits
2つ目のiOSアプリケーション :ストーリーボードを読み進めている中で、P41 に以下のような記述を見つけました。
TableViewオブジェクトは、テーブルの行を表示する必要が生じるたびに cellForRowAtIndexPathメソッドを呼び出します。
そこで実際にこのメソッドが呼ばれるタイミングは何時なのか気になりました。日本語ドキュメントの一覧を眺めてみると iOS Table Viewプログラミングガイド というドキュメントがありましたので、少し読んでみました。
cellForRowAtIndexPathというキーワードで pdf内を検索してみると、いくつか情報が見つかりました。P48 に以下のような記述があります。
次に、tableView:cellForRowAtIndexPath:メソッドを繰り返し呼び出して、表示する各行のセルオブジェクトを取得します。Table Viewは、このUITableViewCellオブジェクトを使用して、その行のコンテンツを描画します(Table Viewがスクロールされた場合にも、新たに表示する行に対してtableView:cellForRowAtIndexPath:が呼び出されます)。

ネットを繰る間に様々な情報に接し 又上記参考記事を読むなどして曖昧模糊としてていた問題点が漸く形を成して来ました。 どうやら実装に記述していた tableView:cellForRowAtIndexPath は画面再描画の度に呼ばれて 配列dataSourceを最初から読み込むのが原因らしくあるのです。 何故に此の如き不慣れな者には一見面倒な仕様になっているかと言えば 此れもネットを繰る間に得られた些か怪しい知見ですが どうやらメモリ節約のためにモバイル機器など比較的厳しい環境にある中の実装では 極々常識的なものとされているようで己の門外漢の程に恥じ入るばかりです。

NSLog(@"%d,%d", indexPath.section, indexPath.row);

参考記事では簡単なアプリを作成して画面上の表組みをスクロールした際のログを出力をしていますが、 真似て開発中のアプリにも上記の様な記述を施し ログを取ってみれば以下の様な出力が得られました。

2014-03-21 09:07:09.431 uPanda Breaks Out Fruits[676:60b] 0,0
2014-03-21 09:07:09.434 uPanda Breaks Out Fruits[676:60b] 0,1
2014-03-21 09:07:09.436 uPanda Breaks Out Fruits[676:60b] 1,0
2014-03-21 09:07:09.436 uPanda Breaks Out Fruits[676:60b] 1,1
2014-03-21 09:07:09.437 uPanda Breaks Out Fruits[676:60b] 2,0
2014-03-21 09:07:09.437 uPanda Breaks Out Fruits[676:60b] 2,1
2014-03-21 09:07:10.942 uPanda Breaks Out Fruits[676:60b] 2,2
2014-03-21 09:07:10.976 uPanda Breaks Out Fruits[676:60b] 3,0
2014-03-21 09:07:11.192 uPanda Breaks Out Fruits[676:60b] 3,1
2014-03-21 09:07:11.638 uPanda Breaks Out Fruits[676:60b] 4,0
2014-03-21 09:07:12.488 uPanda Breaks Out Fruits[676:60b] 2,1
2014-03-21 09:07:34.065 uPanda Breaks Out Fruits[676:60b] 2,0
2014-03-21 09:07:35.352 uPanda Breaks Out Fruits[676:60b] 1,1
2014-03-21 09:07:35.403 uPanda Breaks Out Fruits[676:60b] 1,0
2014-03-21 09:07:36.448 uPanda Breaks Out Fruits[676:60b] 0,1
2014-03-21 09:07:36.483 uPanda Breaks Out Fruits[676:60b] 0,0
2014-03-21 09:07:38.008 uPanda Breaks Out Fruits[676:60b] 2,1
2014-03-21 09:07:41.895 uPanda Breaks Out Fruits[676:60b] 2,2
2014-03-21 09:07:41.947 uPanda Breaks Out Fruits[676:60b] 3,0
2014-03-21 09:07:42.208 uPanda Breaks Out Fruits[676:60b] 3,1
2014-03-21 09:07:42.825 uPanda Breaks Out Fruits[676:60b] 4,0
2014-03-21 09:07:43.441 uPanda Breaks Out Fruits[676:60b] 2,1
2014-03-21 09:07:44.712 uPanda Breaks Out Fruits[676:60b] 2,0
2014-03-21 09:07:44.781 uPanda Breaks Out Fruits[676:60b] 1,1
2014-03-21 09:07:44.910 uPanda Breaks Out Fruits[676:60b] 1,0
2014-03-21 09:07:45.226 uPanda Breaks Out Fruits[676:60b] 0,1
2014-03-21 09:07:45.459 uPanda Breaks Out Fruits[676:60b] 0,0
2014-03-21 09:07:48.531 uPanda Breaks Out Fruits[676:60b] 2,2
2014-03-21 09:07:48.617 uPanda Breaks Out Fruits[676:60b] 3,0
2014-03-21 09:07:48.854 uPanda Breaks Out Fruits[676:60b] 3,1
2014-03-21 09:07:49.254 uPanda Breaks Out Fruits[676:60b] 4,0
2014-03-21 09:07:51.083 uPanda Breaks Out Fruits[676:60b] 2,1
2014-03-21 09:07:52.807 uPanda Breaks Out Fruits[676:60b] 2,0
2014-03-21 09:07:52.909 uPanda Breaks Out Fruits[676:60b] 1,1
2014-03-21 09:07:53.038 uPanda Breaks Out Fruits[676:60b] 1,0
2014-03-21 09:07:53.421 uPanda Breaks Out Fruits[676:60b] 0,1
2014-03-21 09:07:53.722 uPanda Breaks Out Fruits[676:60b] 0,0

上の出力ログを見れば画面をスワイプして得られるスクロールと言う動きの短い時間の中で 繰り返し同じ座標が出力されているのが分明になります。 此処に座標とはテーブルに於けるセルの座標を指します。 実際に手元の環境では開発中のアプリの設定画面をスクロールさせる毎に 上の様に連綿とログが吐き出される状態です。 プログラムを併せ鑑みれば即ち以下記述を以てセルが重複してしまうようでした。

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
  // tableCellを取得する。
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"]
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
  if (!cell) {
    cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];

    cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
    cell.textLabel.numberOfLines = 0;

    cell.textLabel.text = dataSource[indexPath.section][@"cell"][indexPath.row];

    /* セグメントコントロールの追加 */
    if (indexPath.section == 0 && indexPath.row == 0) {
      // セグメントコントロールを作成する。
      NSArray *items = [[NSArray alloc] initWithObjects:@"SLOW", @"FAST", nil];
      UISegmentedControl *segment = [[UISegmentedControl alloc] initWithItems:items];
      segment.frame = CGRectMake(0, 0, 120, 30);
      // 保存したセグメントの値をdelegateから取得してアクティブ反映
      BOAppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
      NSString *strSelectedIndex = [appDelegate.toyBox objectForKey:@"paddle_speed"];
      int selectedIndex = [strSelectedIndex intValue];
      segment.selectedSegmentIndex = selectedIndex;
      segment.momentary = NO;
      // そのまま載せると少し大きいので、小さめにする。
      NSDictionary *attribute = [NSDictionary dictionaryWithObject:[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote] forKey:NSFontAttributeName];
      [segment setTitleTextAttributes:attribute forState:UIControlStateNormal];
      // セグメントコントロールの値がかわった際に呼び出されるメソッドを指定する。
      [segment addTarget:self action:@selector(segmentChangedPaddleSpeed:) forControlEvents:UIControlEventValueChanged];
      
      // accessoryViewに追加する。
      cell.accessoryView = segment;
    }
    if (indexPath.section == 0 && indexPath.row == 1) {
      // セグメントコントロールを作成する。
      NSArray *items = [[NSArray alloc] initWithObjects:@"ON", @"OFF", nil];
      UISegmentedControl *segment = [[UISegmentedControl alloc] initWithItems:items];
      segment.frame = CGRectMake(0, 0, 120, 30);
      // 保存したセグメントの値をdelegateから取得してアクティブ反映
      BOAppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
      NSString *strSelectedIndex = [appDelegate.toyBox objectForKey:@"sound_effect"];
      int selectedIndex = [strSelectedIndex intValue];
      segment.selectedSegmentIndex = selectedIndex;
      segment.momentary = NO;
      NSDictionary *attribute = [NSDictionary dictionaryWithObject:[UIFont preferredFontForTextStyle:UIFontTextStyleFootnote] forKey:NSFontAttributeName];
      [segment setTitleTextAttributes:attribute forState:UIControlStateNormal];
      // セグメントコントロールの値がかわった際に呼び出されるメソッドを指定する。
      [segment addTarget:self action:@selector(segmentChangedSoundEffect:) forControlEvents:UIControlEventValueChanged];
      
      // accessoryViewに追加する。
      cell.accessoryView = segment;
    }
    
    /* イメージの追加 */
    if ([dataSource[indexPath.section][@"cell"][indexPath.row] isEqual: @"Python"]) {
      cell.imageView.image = [UIImage imageNamed:@"ball"];
    }
  }
  return cell;
}

上記コードを見れば描画されるセルのidentifierにすべて @"Cell" が割り当てられているため重複が発生するのであると考えられました。 例えばサイト siro:chro の2013年1月18日の以下の記事では以下に引用する記述があります。

Objective-C:UITableViewCell を再利用した場合、画面スクロールでセルが重複表示される
UITableViewの中で、セクションごとに UITableViewCell を使う場合、テーブル全体がスクリーン内に収まる場合は問題ないが、
 表示が画面サイズ内に収まりきらないとき、新しく描画されたセルにデータが重複して表示されてしまう。
…(略)…
セクションごとにセルの識別子(CellIdentifier)を定義すればこういった問題は発生しない。

如何にも望んだ処の解説が為されているのに喜び 手元のソースを以下の様に流用、編集してみたのですが 恐らくは手元のコードに上手くユニークな識別子を設定出来ていないだけかも知れないとは思いながらも 目論見通りには残念ながら運びません。

if( indexPath.section == 0 ) {
  //ここにはユニークな識別子とインスタンスを定義
  static NSString *identifier = @"Cell0";
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
}
if( indexPath.section == 1 ) {
  //ここにはユニークな識別子とインスタンスを定義
  static NSString *identifier = @"Cell1";
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
}
if( indexPath.section == 2 ) {
  //ここにはユニークな識別子とインスタンスを定義
  static NSString *identifier = @"Cell2";
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
}
if( indexPath.section == 3 ) {
  //ここにはユニークな識別子とインスタンスを定義
  static NSString *identifier = @"Cell3";
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
}
if( indexPath.section == 4 ) {
  //ここにはユニークな識別子とインスタンスを定義
  static NSString *identifier = @"Cell4";
  UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
}

どうやらもう少し厳密にユニークな識別子を指定する必要があるようです。 ユニークな識別子を指定する方法を求めてネットを繰ればサイト hachinoBlog の2012年7月25日の記事が見付かりました。

UITableViewのセルの値がスクロールするごとに重なったり壊れる現象

状況は又正しく求める其の物であるのですが、 はたと 恐らくはXcodeの仕様が変わり NSString *CellIdentifier = [identifiers objectAtIndex:[indexPath section]]; が、詰まりindexPathを変数にして静的文字列に当てようとすると Initializer element is not a compaile-time constand と警告が出力されてビルド出来ないのではないかと思い当たったのでした。 其処で以下のコードを以て対処するとどうやらビルドに成功します。

static NSString *identifier;
switch (indexPath.section) {
  case 0:
    identifier = @"Cell0";
    break;
  case 1:
    identifier = @"Cell1";
    break;
  case 2:
    identifier = @"Cell2";
    break;
  case 3:
    identifier = @"Cell3";
    break;
  case 4:
    identifier = @"Cell4";
    break;
  default:
    identifier = @"Cell";
    break;
}

光明が見えた気になって、さて気を取り直して 念の為もう一度参考サイトを参照しながら以下のようにコードを書いてみたら 驚きにも想定通りの動きを示し丸で狐に抓まれた感があります。

// NSArray *identifiers = [NSArray arrayWithObjects:@"a", @"b", @"c",@"d", @"e", @"f", @"g", @"h", @"i", nil];
// NSString *identifier = [identifiers objectAtIndex:[indexPath section]];
NSString *identifier = [NSString stringWithFormat:@"Cell%d", indexPath.section];
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];

何故一度警告でビルド出来なかったのか見当が付かないのは恥ずかしながらも 取り敢えずは其の場凌ぎの配列は拵えたくなく、 大凡の理屈は把握出来た上で先ずは己が思う通りの viewDidLoad で生成した配列の要素数に応じたセル数に対応してユニーク性を保持出来たので良しとしたいのですが、 実は未だ問題を孕んでいるのが発覚するのは後のお話、 その際の状況は又記事にして配信する予定でいます。

uPanda Breaks Out Fruits
無料:カテゴリ: ゲーム: 4+ 評価
バージョン: 4.12
リリース: 2014年9月15日
更新: 2022年4月20日
サイズ : 10.7 MB
互換性: iOS 14.4 以降のiPhone、iPod touch に対応。および、macOS 11.0以降とApple M1 チップを搭載したMac に対応。