Некоторое время назад я писал об использовании Шаблонов нескольких элементов в NativeScript ListView и кратко затронул темы Виртуализация пользовательского интерфейса и Повторное использование представления / компонентов. Похоже, есть некоторые скрытые ловушки, которые вы можете поразить при разработке приложений с помощью ListView, связанных с этим, особенно если вы используете Angular Components в качестве элементов в ListView и сохраняете некоторое состояние в компонентах.

Мы глубоко погрузимся в проблему и покажем некоторые подходы к ее преодолению.

Сценарий

Чтобы продемонстрировать проблему, мы создадим приложение, которое отображает список элементов, и мы хотим иметь возможность выбирать некоторые из них.

Для этого блога мы будем использовать проект, который использует общий код для Интернета и мобильных устройств. Причины:

  1. Мы можем выделить различия между шаблонами Web и NativeScript.
  2. Совместное использование кода теперь невероятно просто благодаря 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, который сделает всю работу за меня. Вот как это происходит:

  1. Мы украшаем специальное свойство состояния представления в компоненте (назовем его для краткостиvs) с помощью нашего специального декоратора: @attachViewState.
  2. Мы даем декоратору фабричную функцию для создания объекта состояния просмотра по умолчанию для элементов. Он будет использовать его всякий раз, когда ему нужно создать объект состояния просмотра для элемента.
  3. Мы даем декоратору имя фактического свойства модели в компоненте. Обычно это свойство @Input - в нашем случае «элемент».
  4. Декоратор создаст (используя фабрику) и «прикрепит» объект состояния просмотра к каждому элементу, переданному компоненту («прикрепить» - это причудливый способ сказать, что он установит свойство "__vs" для элемента).
  5. Декоратор также изменит геттер и сеттер для свойства 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

Резюме

Вот основные выводы:

  1. При переключении с *ngFor на ListView имейте в виду, что он перерабатывает компоненты вашего шаблона. Любое состояние внутри них (все не @Input свойства, которые не связаны в шаблоне) переживет повторную переработку и, вероятно, вызовет нежелательное поведение.
  2. Рассмотрите возможность использования компонентов без сохранения состояния (также известных как презентация). Это избавит вас от проблем в 1., поскольку все состояния будут передаваться как входные. Он также соответствует руководствам Умные компоненты против презентационных компонентов и приведет к лучшей архитектуре вашего приложения.
  3. Бонус: Совместное использование кода между Интернетом и мобильным телефоном с помощью NativeScript теперь действительно просто. Не совсем по теме ... но я взволнован и решил поделиться 😃😃😃