Некоторое время назад я писал об использовании Шаблонов нескольких элементов в NativeScript ListView и кратко затронул темы Виртуализация пользовательского интерфейса и Повторное использование представления / компонентов. Похоже, есть некоторые скрытые ловушки, которые вы можете поразить при разработке приложений с помощью ListView, связанных с этим, особенно если вы используете Angular Components в качестве элементов в ListView и сохраняете некоторое состояние в компонентах.
Мы глубоко погрузимся в проблему и покажем некоторые подходы к ее преодолению.
Сценарий
Чтобы продемонстрировать проблему, мы создадим приложение, которое отображает список элементов, и мы хотим иметь возможность выбирать некоторые из них.
Для этого блога мы будем использовать проект, который использует общий код для Интернета и мобильных устройств. Причины:
- Мы можем выделить различия между шаблонами Web и NativeScript.
- Совместное использование кода теперь невероятно просто благодаря angular-cli и @ nativescript / schematics. Узнайте больше об этом в этом замечательном блоге Себастьян Виталек
Вот как приложение будет выглядеть в браузере и симуляторе iOS:
Каждый элемент в списке отображается с помощью ItemComponent
- с текущими элементами в качестве параметра @Input
. Вот класс компонента:
@Component({ selector: 'app-item', templateUrl: './item.component.html', styleUrls: ['./item.component.css'] }) export class ItemComponent { @Input() item: Item; selected: boolean = false; }
Обратите внимание, что мы сохраняем состояние selected
как поле в компоненте. Мы также используем его в нескольких местах в шаблоне:
// Mobile Template (item.component.tns.html) <StackLayout orientation="horizontal" class="item" (tap)="selected = !selected"> <Label [text]="item.name"></Label> <Label class="select-btn" [class.selected]="selected" [text]="selected ? 'selected' : 'unselected'"> </Label> </StackLayout> // Web Template (item.component.html) <div class="item"> {{ item.name }} <span (click)="selected = !selected" class="select-btn" [class.selected]="selected"> {{ selected ? 'selected' : 'unselected' }} </span> </div>
Здесь находится весь проект вместе с ветками для разных разделов блога:
Использование старого доброго * ngFor
Мы начнем с отображения всех элементов модели в контейнере (также известном как умный) компонент, использующий *ngFor
:
<app-item *ngFor="let item of items" [item]="item"></app-item>
Довольно прямолинейно! Это отобразит ItemComponent
для каждого элемента в коллекции.
В тестовом проекте сгенерировано 100 элементов, и все работает очень быстро как для Интернета, так и для мобильных устройств.
😈😈😈 Давай попробуем еще раз 😈😈😈
Веб-приложение начинает значительно задерживаться при запуске при 10 КБ элементов. В мобильных пунктах порог намного ниже - около 2К. Это связано с тем, что собственные компоненты, отображаемые IOS / Android, дороже, чем элементы DOM браузера. Если мы сделаем шаблон более сложным, эти цифры уменьшатся.
Но… никто не вносит 2000 пунктов в список, который вы бы сказали. И ты прав. Вы, вероятно, реализуете бесконечную прокрутку с механикой загрузки по требованию. Дело в том, что даже в этом случае вы столкнетесь с проблемами производительности и памяти при прокрутке, поскольку *ngFor
будет создавать все больше и больше ItemComponents
по мере прокрутки вниз и получения дополнительных данных.
Вот код, чтобы вы могли поиграть с ним сами - просто настройте item.service.ts, чтобы сгенерировать больше элементов: ngFor branch.
Мы можем лучше!
Переключиться на ListView в NS
В NativeScript мы используем собственные элементы управления, которые выполняют виртуализацию пользовательского интерфейса и повторное использование представления / компонентов. Это означает, что будут созданы только элементы пользовательского интерфейса для видимых элементов, и эти элементы пользовательского интерфейса будут переработаны (или повторно использованы) для отображения новых элементов, которые появляются в поле зрения.
Чтобы начать использовать ListView
, нам просто нужно изменить шаблон на основе * ngFor, указанный выше, на:
<ListView [items]="items"> <ng-template let-item="item"> <app-item [item]="item"></app-item> </ng-template> </ListView>
Большой! Быстрый тест показывает, что теперь мы можем без проблем прокрутить 100K items
в мобильном приложении!
Простой счетчик в конструкторе ItemComponent's
показывает, что когда-либо создается только 13 экземпляров. Они используются повторно, чтобы отображать все элементы при прокрутке.
Эта проблема
Аккуратный! … либо это? Посмотрим, что произойдет, когда мы начнем выбирать элементы:
Здесь мы видим проблему, которая на самом деле является причиной этого сообщения. Выбираю первые 3 пункта. Когда я прокручиваю вниз, выбираются также пункты 13, 14 и 15. Далее выбираются другие предметы, которых я никогда раньше не видел.
Причина этого в том, что при повторном использовании ItemsComponents
состояние, которое находится внутри них, также используется повторно. Когда-либо было создано всего 13 компонентов, поэтому, если вы выберете 3 из них, вы увидите, как они появляются снова и снова при прокрутке.
Если подумать - с этой реализацией вы фактически выбираете компоненты, а не элементы. И между этими двумя коллекциями больше нет отношения 1: 1: есть 100 (или, может быть, 100K😈) элементов и только 13 ItemsComponent
экземпляров.
Вот ветка в репо, в которой есть проблема: list-view-state-in-component branch.
Решение
Есть несколько решений, но все они в конечном итоге сводятся к следующему:
Переместите состояние просмотра (поле
selected
в нашем примере) из компонента t и сделайте компонент без состояния.
Мы будем ссылаться на состояние просмотра (из-за отсутствия лучшего термина) для всей информации, которая изначально не была в модели, но все еще используется в шаблонах компонентов и логике приложения. В нашем случае это selected
filed. Эта информация также может быть привязана к любому представлению ввода в вашем шаблоне.
Примечание. Один из альтернативных подходов, который приходит на ум, - это попытаться «очистить» компоненты при их повторном использовании. Однако это означает, что вы неизбежно потеряете то состояние, в котором они находились. Просто невозможно хранить 100 предметов в 13 коробках с одним предметом.
Сохранение состояния просмотра в модели
Возможно, самое простое в реализации решение - просто добавить состояние просмотра в элементы модели:
export interface Item { name: string; selected?: boolean; }
Вам нужно будет изменить шаблон компонента, чтобы получить / установить поле selected
из item
:
<StackLayout orientation="horizontal" class="item" (tap)="item.selected = !item.selected"> <Label [text]="item.name"></Label> <Label class="select-btn" [class.selected]="item.selected" [text]="item.selected ? 'selected' : 'unselected'"> </Label> </StackLayout>
Задача решена! Чтобы было понятнее. Мы перешли от ngFor с компонентами с отслеживанием состояния:
в ListView (по-прежнему ngFor в веб-версии) с компонентами без сохранения состояния:
Примечание. В веб-шаблонах по-прежнему используется ngFor
. Он отлично работает с версией ItemComponent
без сохранения состояния. Вот ветка в репо: ветка list-view-state-in-model
Для простых случаев это подходящее решение, но вы можете не захотеть смешивать свойства состояния представления с моделью. Или может случиться так, что вы получаете объект модели непосредственно из службы и хотите, чтобы они были «чистыми» из дополнительного поля, чтобы вы могли в какой-то момент отправить их обратно.
Прикрепить View-State к элементу
Другой подход заключался бы в том, чтобы иметь состояние представления как отдельный объект состояния представления и «прикреплять» его к объекту модели, когда он используется в пользовательском интерфейсе. Это даст нам некоторое разделение между свойствами модели и состояния представления и простой способ очистки объектов модели при необходимости.
Чтобы упростить задачу, я создал декоратор TypeScript, который сделает всю работу за меня. Вот как это происходит:
- Мы украшаем специальное свойство состояния представления в компоненте (назовем его для краткости
vs
) с помощью нашего специального декоратора:@attachViewState
. - Мы даем декоратору фабричную функцию для создания объекта состояния просмотра по умолчанию для элементов. Он будет использовать его всякий раз, когда ему нужно создать объект состояния просмотра для элемента.
- Мы даем декоратору имя фактического свойства модели в компоненте. Обычно это свойство
@Input
- в нашем случае «элемент». - Декоратор создаст (используя фабрику) и «прикрепит» объект состояния просмотра к каждому элементу, переданному компоненту («прикрепить» - это причудливый способ сказать, что он установит свойство
"__vs"
для элемента). - Декоратор также изменит геттер и сеттер для свойства
vs
, чтобы они получили доступ к объекту состояния представления, который находится внутри элемента. Это упростит использование состояния просмотра внутри шаблона компонента.
Звучит сложно? На самом деле пользоваться им довольно просто:
interface ItemViewState { selected?: boolean; } const ItemViewStateFactory = () => { return { selected: false } }; @Component({ ... }) export class ItemComponent { @attachViewState<ItemViewState>("item", ItemViewStateFactory) vs: ItemViewState; @Input() item: Item; }
И в шаблоне мы просто используем vs
для свойств состояния просмотра и item
для свойств данных:
<StackLayout orientation="horizontal" class="item" (tap)="vs.selected = !vs.selected"> <Label [text]="item.name"></Label> <Label class="select-btn" [class.selected]="vs.selected" [text]="vs.selected ? 'selected' : 'unselected'"> </Label> </StackLayout>
Вот также код декоратора @attachViewState
(T - это тип объекта состояния просмотра). Существуют также вспомогательные методы getViewState
и cleanViewState
для получения и очистки объекта состояния представления из модели.
Опять же, код здесь: ветвь list-view-state-in-model-decorator
Примечание: есть и другие тактики. Например:
- поддерживать полностью разделенный список объектов состояния просмотра в вашем компоненте контейнера и передавать их как входные данные шаблона
- «Оберните» элемент модели в элементы модели представления, используя композицию, таким образом, полностью сохраняя элементы модели нетронутыми.
Бонус (случай для компонентов без сохранения состояния)
Стоит отметить, что это решение безупречно работает в веб-версии нашего приложения, где *ngFor
все еще используется. Фактически, во многих случаях наличие компонентов без сохранения состояния фактически приведет к лучшей архитектуре приложения.
Вот пример. Рассмотрим следующую функцию в нашем приложении: мы должны собрать все выбранные элементы и отобразить их в другом представлении (или просто alert
их пока 😃).
Если «выбранная» информация находится внутри компонентов, нам придется либо:
- Используйте
@ViewChildren
для запроса компонентов, чтобы выяснить, какие из них выбраны. 🤮Eew! 🤮 - Предоставьте какое-то событие для уведомления всякий раз, когда элемент выбран, и обработайте его в компоненте контейнера. Это означает, что мы будем хранить «выбранную» информацию в двух разных местах (один раз в
ItemComponent
и один раз в компоненте-контейнере). Eeeew! 🤮🤮
С другой стороны, если у вас есть состояние без состояния ItemComponent
и вы храните состояние отдельно, вам будет легче работать с данными. Вот как выглядит код, если вы используете подход «декоратора» сверху (мы используем метод getViewState
из вспомогательной утилиты для получения состояния просмотра):
// In container-component template (home.component.html): ... <button (click)="checkout()">checkout</button> ... // In container-component code (home.component.ts): ... checkout() { const result = this.items .filter(item => { const vs = getViewState<ItemViewState>(item); return vs && vs.selected; }) .map(item => item.name) .join("\n"); alert("Selected items:\n" + result); }
Код финального проекта: master branch
Резюме
Вот основные выводы:
- При переключении с
*ngFor
наListView
имейте в виду, что он перерабатывает компоненты вашего шаблона. Любое состояние внутри них (все не@Input
свойства, которые не связаны в шаблоне) переживет повторную переработку и, вероятно, вызовет нежелательное поведение. - Рассмотрите возможность использования компонентов без сохранения состояния (также известных как презентация). Это избавит вас от проблем в 1., поскольку все состояния будут передаваться как входные. Он также соответствует руководствам Умные компоненты против презентационных компонентов и приведет к лучшей архитектуре вашего приложения.
- Бонус: Совместное использование кода между Интернетом и мобильным телефоном с помощью NativeScript теперь действительно просто. Не совсем по теме ... но я взволнован и решил поделиться 😃😃😃