QA@IT

UICollectionViewLayoutのDecorationViewが再利用されない/deallocもされない

3419 PV

UICollectionViewのカスタムレイアウトについて、
Itemごとにデコレーションビューを配置する実装を試してみたのですが、
以下のような挙動になってしまいます。

  • デコレーションビューは再利用されず、毎回インスタンス化される (prepareForReuseもコールされない)
  • スクロールアウトしたビューについてもdeallocされない
  • スクロールを戻して再表示された時にも新しいインスタンスが生成される

期待としては、

  • UICollectionViewCellやSupplementaryViewのようにキューベースの再利用をしてほしい
  • さもなくば、同じIndexPathについてだけでも…

この件について、なにか回避策、あるいはなんらかの情報(既知バグであるとか)を
もっている人はいますでしょうか?

問題が再現するUICollectionViewLayout実装

@interface ReproductionLayout : UICollectionViewLayout
@end

@interface DecoView : UICollectionReusableView
@end

@implementation ReproductionLayout
- (id)init { // コードで初期化する場合
    self = [super init];
    if (self) {
        [self registerClass:[DecoView class] forDecorationViewOfKind:@"DecoView"];
    }
    return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder { // Storyboardを使う場合
    self = [super initWithCoder:aDecoder];
    if (self) {
        [self registerClass:[DecoView class] forDecorationViewOfKind:@"DecoView"];
    }
    return self;
}
- (CGSize)collectionViewContentSize {
    CGSize size = self.collectionView.bounds.size;
    size.height = [self.collectionView numberOfItemsInSection:0] * 100;
    return size;
}
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
    NSMutableArray *array = [NSMutableArray array];
    int count = [self.collectionView numberOfItemsInSection:0];
    for (int i=MAX(0, (int)(rect.origin.y / 100)); i<count && i*100<rect.origin.y + rect.size.height; i++) {
        NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:0];
        [array addObject:[self layoutAttributesForItemAtIndexPath:indexPath]];
        [array addObject:[self layoutAttributesForDecorationViewOfKind:@"DecoView" atIndexPath:indexPath]];
    }
    return array;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *attr = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
    CGFloat y = indexPath.item * 100;
    CGFloat x = (y - self.collectionView.contentOffset.y) / 2;
    attr.frame = CGRectMake(x, y, 80, 80);
    return attr;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForDecorationViewOfKind:(NSString *)decorationViewKind atIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewLayoutAttributes *attr = [UICollectionViewLayoutAttributes layoutAttributesForDecorationViewOfKind:decorationViewKind withIndexPath:indexPath];

    attr.frame = CGRectMake(0, indexPath.item * 100 + 80, self.collectionView.bounds.size.width, 20);
    return attr;
}
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
    return YES;
}
@end

@implementation DecoView
- (void)drawRect:(CGRect)rect {
    [[UIColor blueColor] set];
    [[UIBezierPath bezierPathWithRect:rect] fill];
    [[UIColor whiteColor] set];
    [[self description] drawInRect:rect withFont:[UIFont systemFontOfSize:12.0]];
}
- (void)prepareForReuse {
    NSLog(@"prepareForReuse -- NEVER CALL");
}
- (void)dealloc {
    NSLog(@"dealloc -- NEVER CALL until superview's dealloc");
}
@end

上記レイアウトを使用する DataSource (UICollectionViewControllerを継承) の実装

@interface MyViewController : UICollectionViewController
@end

@implementation MyViewController

- (id)init {
    return [super initWithCollectionViewLayout:[[ReproductionLayout alloc] init]];
}

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
}

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return 1;
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return 30;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
    cell.backgroundColor = [UIColor whiteColor];
    return cell;
}
@end

追記

DataSource側の実装を追加しました。
ただし、DecorationViewはUICollectionViewLayoutによって提供されるものであるため、
本質的にはDataSourceは無関係です。

  • UICollectionViewDataSourceのソースコードも提示してください。 -
  • DataSourceのソースを追加しました。DecorationViewはLayout側から提供されるものなので、DataSource側は上記のようなごく簡単な実装で再現されます。 -
  • まだ詳しくは分かりませんが、「DecorationViewを提供しているのはどこなのか?」というよりは、「DecorationViewの再利用を判断しているのはどこなのか?」という話なのかなと思います。 -
  • registerClass:forDecorationViewOfKind:のAPI記載にもあるように、DecorationViewの生成や再利用は完全に自動的に行われ、隠蔽されています。
    class-dumpで見つけた非公開メソッドをオーバーライドして自前の再利用機構を組むことはできましたが、レビューを通過しないような方法では意味がないですね…。
    -

回答

提示して頂いたソースコードを元に、こちらでサンプルアプリを作り動作を検証しました。
そして、症状が再現することを確認しました。

結論から言うと、これはバグです。

DecorationViewの再利用は、UICollectionViewLayoutではなくUICollectionViewの中で行われています。
UICollectionViewは、DecorationViewやSupplementaryViewをプールするためのNSDictionaryなオブジェクトを持っており、今回のケースでは"DecoView/DecoView"というキーでちゃんとプールされています。
そして、プールしたDecorationViewを取り出すときに、"UICollectionElementKindDecorationView/DecoView"というキーを使用しています。
当然キーが一致しないのでnilが返ってきてしまい、結果プールしているオブジェクトは無いとうことになり、新規に生成されます。
DecorationViewの新規生成は、UICollectionViewLayoutが行っています。

回避策は、これといって思いつきませんが、、、
例えば、UICollectionView全体を覆うDecorationViewを作れば、絶対に画面外に出て行かないので1度しか生成されないはずです。とりあえず、これでメモリリークは防げそうかな?

編集 履歴 (0)
  • 検証ありがとうございますした。おそらく、登録する時のキーが間違ってるんでしょうね…。
    WWDCのスライドにあった、背景画像を設置するような実装しかテストしてなかったのでしょう。
    この件については、Appleにバグレポも送ってあります (英語力が微妙なのですが…)。
    -
ウォッチ

この質問への回答やコメントをメールでお知らせします。