Пользователь
0,0
рейтинг
2 февраля 2014 в 23:13

Разработка → UICollectionView или танцы с волками

The dream


UICollectionView — класс UIKit, появившийся в iOS 6. Строго говоря, это класс, позволяющий показывать на экране коллекцию айтемов. Структура коллекции — абсолютно произвольная, но обычно UICollectionView используется для всяких сетко-подобных контролов с ячейками, хедерами и футерами. Понимая, насколько абстрактен данный класс, разработчики Apple создали мощный механизм для создания любых лейаутов. По большому счету, даже UITableView это конкретная реализация UICollectionView. Возможности данного класса, в каком-то смысле, фантастические. Но в данной статье речь пойдет не об этом.

Ахиллесова пята разработчиков Apple — постоянное стремление делать СДК, которое будет работать «автомагически». Просто сделайте то-то и то-то, и класс «will do the right thing». К сожалению это работает далеко не всегда. И UICollectionView — яркий пример. Начиная с релиза в iOS 6 и по сегодняшний день (iOS 7.0.4) класс содержит довольно большое количество багов, с которыми очень трудно и неприятно иметь дело. Приходится угадывать, что же происходит «под капотом», и методом тыка заставлять UICollectionView работать как надо. Количество приобретенных костылей уже достигло таких размеров, что я решил поделиться известными багами и найденными решениями.

Кому интересно — милости просим под кат.

Reality


Перед тем как начать описывать баги, я попробую описать «идеальный сценарий», в котором UICollectionView работает стабильно, без каких-либо костылей. Как правило это UICollectionView, в котором нет хедеров, и футеров, только ячейки. Когда контроллер загрузился в память(viewDidLoad:), вызывается метод UICollectionView -reloadData. Анимированных удалений, перемещений, и вставок не делается. Вот такой идеальный сценарий для UICollectionView без костылей. Как вы видите, он довольно сильно ограничен.

Сразу оговорюсь, что вы можете не встретить баги из списка, который я приведу ниже. Каждый баг находился в специфических условиях, которых у вас может не быть. И каждое решение тоже помогало в моем конкретном случае, нельзя гарантировать, что оно поможет вам, и что оно будет продолжать работать в следующих релизах iOS. Многие из костылей будут выглядеть уродливо, но какой костыль не уродлив? Однако если что-то поможет — буду счастлив =). Поехали!

iOS 6 + iOS 7


1. Нельзя вставлять первый UICollectionViewCell в секции. Также нельзя удалять последний оставшийся UICollectionViewCell. Попытка это сделать приводит к крашу в дебаг билдах, и непредсказуемому поведению в релиз билдах:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'request for index path for global index 1 when there are only 1 items in the collection view'

Комментарий. Самый известный баг с UICollectionView. Открыт радар на баг-трекере Apple. Самое популярное и стабильное решение — вызывать reloadData. Иногда может помочь вызвать метод insertItemsAtIndexPaths: в try-catch блоке(да, нога безжалостно отстрелена).

@try {
    [self.collectionView insertItemsAtIndexPaths:indexPaths];
}
@catch (NSException *exception) {}

Еще одно решение, которое пожалуй не стоит использовать, но стоит упоминания, это вызов reloadData внутри performBatchUpdates блока.

[self.collectionView performBatchUpdates:^{
        [self.collectionView reloadData];
    } completion:nil];

2. Попытка выполнить две операции анимации одновременно без performBatchUpdates блока приведет к падению приложения.

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of sections. The number of sections contained in the collection view after the update (1) must be equal to the number of sections contained in the collection view before the update (0), plus or minus the number of sections inserted or deleted (0 inserted, 0 deleted).'

Комментарий. Данный пункт может показаться незначительным, но на самом деле он очень важен. 80% моих личных проблем с UICollectionView возникало тогда, когда возникала необходимость сгруппировать несколько анимаций. В частности, это бывает связано с NSFetchedResultsController и обновлениями в базе данных CoreData. Имеется опенсорсное решение от Ash Furrow, позволяющее корректно работать с обновлениями в CoreData.

3. Нельзя обновлять секции и ячейки в одном performBatchUpdates блоке. Под обновлением подразумевается вставка или удаление.

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of sections. The number of sections contained in the collection view after the update (1) must be equal to the number of sections contained in the collection view before the update (0), plus or minus the number of sections inserted or deleted (0 inserted, 0 deleted).'

Комментарий. Довольно странная проблема, помогают два последовательных performBatchUpdates блока, в первом блоке — обновляются секции, во втором — ячейки.

4. Нельзя вызывать performBatchUpdates сразу за вызовом reloadData.

attempt to delete section 0, but there are only 0 sections before the update

Комментарий. Такое ощущение, что reloadData в UICollectionView работает асинхронно, в отличие от UITableView.

Обновление от 05.02.2014 Имеется костыльный способ вызвать reloadData синхронно. Для этого необходимо создать новый объект UICollectionViewLayout и вызвать метод

[collectionView setCollectionViewLayout:layout animated:NO];

Метод внутри вызовет приватный синхронный reloadData. Решение работает ТОЛЬКО если в UICollectionView уже были элементы/ячейки. Если ячеек не было — на первый раз не произойдет ничего, на второй раз вас ждет краш. Спасибо пользователю PALKOVNIK за подсказанный хак.

Обновление от 03.03.2014 Второй костыльный способ синхронно обновить UICollectionView:

[collection.collectionView reloadData];
[collection.collectionView performBatchUpdates:nil completion:nil];


5. При использовании UICollectionViewFlowLayout, если headerSize или footerSize отличается от нуля, метод collectionView:viewForSupplementaryElementOfKind:atIndexPath: не должен возвращать nil, иначе — краш.

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'the view returned from -collectionView:viewForSupplementaryElementOfKind:atIndexPath (UICollectionElementKindSectionFooter,<NSIndexPath: 0x8e55df0> {length = 2, path = 0 — 0}) was not retrieved by calling -dequeueReusableSupplementaryViewOfKind:withReuseIdentifier:forIndexPath: or is nil ((null))'

Комментарий. Если у вас UICollectionView, в котором header или footer могут отсутствовать, необходимо определить делегатовский метод

 -(CGSize)collectionView:(UICollectionView *)collectionView
                 layout:(UICollectionViewFlowLayout *)collectionViewLayout
referenceSizeForHeaderInSection:(NSInteger)sectionNumber

И в случае если у вас в данной секции отсутствует header, возвращать CGSizeZero.

iOS 6


1. UICollectionViewFLowLayout. Если у вас имеется секция без ячеек, и вы не определили делегатовский метод collectionView:layout:referenceSizeForHeaderInSection:, вам не повезло, краш рантайм.

*** Assertion failure in -[UICollectionViewData indexPathForItemAtGlobalIndex:]
request for index path for global index 805306368 when there are only 0 items in the collection view

Комментарий. Необходимо определить метод collectionView:layout:referenceSizeForHeaderInSection:, и в случае если в секции нет ячеек, вернуть CGSizeZero.

2. Иногда после вызова reloadData старые ячейки остаются на месте, несмотря на то, что методы UICollectionViewDatasource четко возвращают, что их нет. Особенно часто проявляется, когда UICollectionView закрыт клавиатурой, или другим UIView. Решения к сожалению нет.

iOS 7


1. В случае, если ячейка последняя в секции, а также наличия футеров в UICollectionView, вызов метода moveItemAtIndexPath:toIndexPath: для данной ячейки в другую секцию приведет к падению

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'no UICollectionViewLayoutAttributes instance for -layoutAttributesForSupplementaryElementOfKind: UICollectionElementKindSectionFooter at path <NSIndexPath: 0xc054730> {length = 2, path = 0 — 0}'

Комментарий. Данная проблема очень напоминает вставку и удаление первого айтема в секции, поэтому и решение такое-же, вызываем reloadData в случае, если айтем последний.

2. Удаление секции в тот момент, когда UICollectionView не показывается на экране, приводит к падению.

*** Assertion failure in -[UICollectionViewData validateLayoutInRect:], /SourceCache/UIKit_Sim/UIKit-2903.23/UICollectionViewData.m:341
2014-01-10 17:34:55.198 SMS-Bank[47090:70b] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UICollectionView recieved layout attributes for a cell with an index path that does not exist: <NSIndexPath: 0xc000000000000056> {length = 2, path = 1 — 0}'

Комментарий. В тот момент, когда UICollectionView будет исчезать, зануляем делегат и датасорс.

self.collectionView.delegate = nil;
self.collectionView.dataSource = nil;


3. iOS simulator, iPad 2 и iPad 3 не используют reuse для UICollectionViewCell. Также реюз может перестать работать, если включены Accessibility shortcuts.

Комментарий.

Более подробно можно прочитать на стэке: http://stackoverflow.com/questions/19276509/uicollectionview-do-not-reuse-cells
Радар: http://openradar.appspot.com/15357491

The end


Список может быть далеко не полон, к сожалению UICollectionView довольно нестабилен. Надеюсь, когда-нибудь разработчики Apple доведут его до ума. А пока-что — могу посоветовать библиотеку, которая была создана для удобной работы с UICollectionView.

Собственно, именно в процессе написания данной библиотеки автор и нашел большую часть багов, описанных в статье. Целью данного фреймворка никогда не был фикс багов iOS SDK, однако на сегодняшний день костыли, добавленные для iOS 6 и iOS 7, стали одной из важных фич. Возможность забыть о кошмаре под названием UICollectionView + NSInternalInconsistencyException — бесценна. Если у вас есть альтернативные решения, либо информация о других багах UICollectionView — поделитесь ею в комментариях!

Спасибо за внимание!
@DenHeadless
карма
22,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (25)

  • 0
    Часто люди любят сразу называть багами то, что они не понимают и с чем неправильно работают. Я не говорю конкретно о Вас и всех тех вещах, которые Вы описали, но часть из них все же надуманные.

    — reloadData действительно работает асинхронно. Я не нашел подтверждения этому в документации, но проверить это не сложно, достаточно посмотреть в какой последовательности выполняются методы DataSource для этой UICollectionView.

    — «Попытка выполнить две операции анимации одновременно без performBatchUpdates блока приведет к падению приложения. „
    на мой взгляд, ве логично и обоснованно. Вы начинаете изменение данных, оно анимируется, все это происходит асинхронно, не дождавшись завершения, Вы начинаете вторую операцию, которая тоже изменяет данные, что же должно произойти?!

    и так далее.
    • +1
      Статья и не претендует на обучалку, как правильно работать с UICollectionView. Документация же, к сожалению, не содержит ответов на те проблемы, с которыми я неоднократно сталкивался. Я тоже считаю, что костыль — это просто признак того, что разработчик в каком-то смысле сдался, и не нашел корректного и стабильного решения проблемы. Но UICollectionView не оставляет другого выбора. Над некоторыми из проблем, перечисленных в статье, я убил по неделе времени, и не нашел решения лучше, чем описал сейчас.

      А самый печальный факт — то, что стабильно работает с UITableView, при абсолютно идентичном использовании — не работает с UICollectionView. Моя библиотека для работы с UITableView — не содержит ни одного костыля в принципе, там все работает стабильно и безошибочно. Хотя архитектура абсолютно идентична тому, что я использую для UICollectionView.
    • +1
      Не понимаю вашего комментария. То, что reloadData работает асинхронно, я тоже узнал из личного опыта, и это, поверьте, было совсем не хорошей новостью — легко это проверить или сложно. А вдруг вам надо сначала перезагрузить данные, а потом сразу же вызвать какую-либо анимацию layout-а?
      • –2
        Учите матчасть.

        Для справки: данные «перезагружает» collectionView.datasource, а никак не [collectionView reloadData]. Последний отвечает за обновление интерфейса в ответ на измененные данные.
        • 0
          Речь идет именно об обновлении интерфейса. UITableView обновляет его синхронно, UICollectionView, несмотря на отсутствие каких-либо анимаций — асинхронно.
          • –3
            Это я понимаю, я отвечал не на Ваш комментарий, а на «сумбурный крик» другого комментирующего.
            Что касается асинхронной работы reloadData в UICollectionView, да, это и у меня порой вызывает сложные для решения проблемы, но приходится с этим жить.
            • 0
              Опечатался ночью, очевидно я писал о перерисовке интерфейса. В самой collection view вообще нет данных, т.к. это юайный класс.

              Смените, пожалуйста, тон. Я подозреваю, что у вас не зря карма в минусе и вы со всеми так разговариваете.
              • –2
                Извините, если обидел, я не хотел этого.
                Что касается кармы, карма на хабре отдельная тема, можно запостить веселую картинку и получить 1000 плюсов, а можно критику по делу высказать и получить 1000 минусов.
  • +1
    Ох, и больную же вы тему подняли! Давно хотел написать подобную статью, все руки не доходили. Сколько ж я с ней намучался за последние полтора года, а как красиво все начиналось… поначалу я был в восторге и от API, и от возможностей. Глубоко уверен, что Apple стоит больше покрывать код юнит-тестами. Из того, что первым вспомнилось без мака под рукой:

    1) UICollectionView плохо обрабатывает очень высокие ячейки. Если высота ячейки — около 5-10 экранов (в зависимости от устройства), она при скроле начинает сначала «снежить», а потом просто пропадает. При этом в Open Source-реализации PSTCollectionView такого бага не было обнаружено.
    2) Знает тот, кто пробовал имитировать стандартное для UITableView «залипание» хэдеров секции. После очередного, довольно рандомного Y в contentOffset UICollectionView она перестает махом спрашивать NSLayoutAttributes для, в моем случае, первых пяти секций, и хоть ты убейся. Пиксель назад спрашивает, пиксель вперед — начинает с шестой.

    Ну и все или почти все ваши случаи, конечно, встречал. Особенно асинхронность, которой не должно быть, или которая должна быть хотя бы управляемой, и проблемы с синхронизацией анимаций — сущий ад. Добавлю один пункт:

    3) Попробуйте запустить какую-либо анимацию в ячейке, и одновременно после этого вызвать reloadData. Крэш обеспечен.

    Эпплу по рукам за нефикс багов функционала, который вышел полтора года назад, и юнит-тесты и еще раз юнит-тесты. И функциональные тесты. Библиотеку пишете, а не RSS reader! Зато живые иконки часов сделать — это пожалуйста, это всем очень нужно…
    • +1
      Я плюнул на залипание с помощью хэдеров и делал нормальный лэйаут поверх всего этого безобразия который следит за положением скролла таблицы. Работает безотказно. Там еще все это завязано на положение нав бара, что доставляет отдельного невероятного геморроя.
      • 0
        Т.е. вы подписываетесь на изменение contentInset-a и рисуете хэдер сами? Был бы очень признателен, если бы вы рассказали про свой способ поподробнее, т.к. очень уж много времени убил на эти хэдэры…
        • +1
          Да. Мне будет тяжело с примерами кода, они на Xamarin. Но смысл с нативным кодом тот же самый, разве что событие скролла может будет где-то в другом месте.

          1. Инициализирую новый View поверх таблицы и связываю его с ней логически. Это наш кастом хэдер (наследован от UIView).
          2. Ловлю событие Scrolled в datasource коллекции. Проверяю наличие кастом хэдера и если он есть, то вызываю его метод определения нового положения.
          3. Обновляю положение вот таким вот методом внутри кастом хэдера:

          //Метод заставляет хэдер плавно прятаться когда таблица скроллится вниз дальше определенного положения и показываться когда таблица скроллится вверх.
          public void Scrolled (MonoTouch.UIKit.UIScrollView scrollView)
          		{
          
          			var topInset = scrollView.ContentOffset.Y + scrollView.ContentInset.Top;
          			float downInset = 200;
          			//fix when scroll down and scroll up
          			if (this.Frame.Y > topInset || this.Frame.Y< downInset ){//<scrollView.ContentInset.Top)
          				this.Frame = new RectangleF (
          					x: this.Frame.X,
          					y: topInset,
          					width: this.Frame.Width,
          					height: this.Frame.Height
          					);
          
          				this.SetNeedsDisplay ();
          			}
          
          		}
          

    • 0
      2) Знает тот, кто пробовал имитировать стандартное для UITableView «залипание» хэдеров секции.

      А в вашем случае можно было использовать такой подход: обернуть коллекцию в таблицу, то есть каждая секция в вашей коллекции — ячейка в таблице, у которой на contentView лежит коллекция? В таблице же количество секций = количеству ячеек.
  • 0
    Также спасибо за ссылку на DTCollectionViewManager, не сомневаюсь что очень полезная вещь. Но есть один существенный недостаток — любой ваш контроллер должен наследоваться от DTCollectionViewController. А т.к. в objective-c нет множественного наследования, наследоваться от чего-то другого невозможно.

    А так конечно да, если данное требование не критично — модель шикарна. Так уже надоело менять Data Source, потом искать index path нужной ячейки и т.п!
    • +1
      Я тоже думал изначально датасорс делать отдельным обьектом, но в итоге отказался от данной затеи, все-таки слишком сложная получается архитектура. Для себя я решил данную проблему таким способом — везде, где используется UICollectionView или UITableView, и контроллер достаточно сложный, я просто добавляю DTCollectionViewController как childViewController. А события при необходимости прокидываю через протокол. Таким образом родительский контроллер можно унаследовать от чего угодно.
  • 0
    Я натыкался ещё на один баг UICollectionView, когда Flow Layout считал начальную координату по оси X ячейки неправильно если у коллекции был установлен contentInset, воспроизводилось стабильно, но только на некоторых инстансах, то есть в одном приложении из двух казалось бы одинаковых UICollectionView на одном стабильно воспроизводится, на другом стабильно всё прекрасно. Ни причины ни тем более решения так и не нашёл.

    Если говорить о UITableView в нём тоже далеко не всё так радужно, прямо сейчас уже больше дня бьюсь головой об стенку. Есть ячейка в xib с включенным auto layout и приложение без него на storyboard (понадобилась динамическая высота ячейки, а приложение полностью переделывать долго), так вот лезет постоянная ошибка с конфликтующими ограничениями среди которых подлый NSAutoresizingMaskLayoutConstraint на contentView, который мало того, что абсолютно непонятно откуда берётся, так ещё и оказывается меньше чем минимальная ширина ячейки, что и влечёт конфликт.

    Вообще для меня лично основной источник проблем это невозможность залезть в базовые библиотеки отладчиком (тут я может и просто не туда смотрю, но не вижу как это сделать), и даже там, куда я могу дотянуться, отладчик частенько не может показать значения полей (Даже frame у контрола какого-нибудь). Да я избалован явой, в которой почти всегда можно докопаться что и почему происходит, тут же я прыгаю вокруг чёрного ящика.
    • +1
      frame можно вывести в консоль через po [NSValue valueWithCGRect:view.frame].
      С констрэйнтами есть такая беда — они очень плохо работают в иерархии вьюх, в которых то используется, то не используется автолэйаут. Мой совет — все же переделать ячейку без autolayout.
      • 0
        есть еще NSStringFromCGRect()
      • 0
        еще есть p view.frame, или p (CGRect)[something frame]
        • 0
          это УЖЕ есть, на момент написания коммента еще не работало :)
          • 0
            наверное :) я до недавних времен, не зная о NSStringFromCGRect юзал CGRectCreateDictionaryRepresentation, что длинновато
            • 0
              о да, как я вас понимаю — еще и intellisense в последнем икскоде тормозит знатно, а на С-шные функции вроде вообще не работало в какой-то версии из последних…
  • 0
    вы проверяли какие-то из этих «багов» на бете 7.1?
    может быть что-то из этого уже пофиксено?
    • 0
      К сожалению нет, на тестирование беты у меня никогда не хватало времени. Как только выйдет финальный релиз, я обязательно проверю основные баги, и обновлю этот пост.
  • 0
    Как мне кажется, 90 % причин всех багов в UICollectionView можно разделить на две категории:
    1) множественные анимированные действия без performBatchUpdates блока;
    2) наличие нескольких секций, хедеров (supplementary view) и футеров (decoration view).
    Первое вроде как не тревожит, если все множественные вставки/удаления делать корректно и по науке (читай в performBatchUpdates) блоке.
    По поводу второго, мне кажется, есть неплохой вариант, который даже вроде бы и не костыль вовсе. Суть в том, чтобы использовать только односекционные коллекции без футеров и хедеров. А уже эти коллекции использовать как ячейки UITableView, который и будет ответственным за возню со всеми этими хедерами и футерами (а он с этим неплохо справляется).
    При секциях, предполагающих большое количество ячеек, конечно, таким грешить не стоит.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.