# 在 NativeScript ListView 中管理组件状态

·

不久前,我写了一篇博客,在 NativeScript ListView 中使用多个项目模板,并简要介绍了 UI 虚拟化和视图/组件回收的主题。似乎在使用与此相关的 ListView 开发应用程序时,您可能会遇到一些隐藏的陷阱,尤其是如果您将 Angular Components 用作 ListView 中的项目并在组件中保持某些状态时。

我们将深入探讨该问题,并展示一些解决该问题的方法。

# 场景

为了演示该问题,我们将构建一个显示项目列表的应用程序,我们希望能够选择其中的一些项目。

  1. 我们将使用一个项目在此博客的 Web 和 Mobile 之间共享代码。原因:
  2. 我们可以概述 Web 和 NativeScript 模板之间的差异。
    由于 angular-cli 和@ nativescript / schematics,代码共享现在变得非常容易。了解更多关于它在这真棒博客由塞巴斯蒂安 Witalec

这是该应用在浏览器和 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

我们将从显示容器(aka smart)组件中使用的模型中的所有项目开始*ngFor:

<app-item *ngFor="let item of items" [item]="item"></app-item>

很简单!这将渲染ItemComponent为每个集合中的项目。

在测试项目中,生成了 100 个项目,对于 Web 和移动设备,一切都非常快。

😈😈😈 让我们尝试更多项目 😈😈😈

该 Web 应用开始在 1 万个项目上出现相当大的启动滞后。在移动设备中,阈值要低得多,大约为 2K。这是因为 IOS / Android 渲染的本机组件比浏览器 DOM 元素昂贵。如果我们使模板更加复杂,这些数字将会下降。

但是…没人会在您要说的清单中放入 2000 个项目。你是对的。您可能会实现按需加载机制的无限滚动。问题是,即使那样,滚动时也会遇到性能和内存问题,*ngForItemComponents向下滚动并提取更多数据时,实例化会越来越多。

这是代码,您可以自己使用它-只需调整item.service.ts以生成更多项目:ngFor branch

我们可以做得更好!

# 切换到 NS 中的 ListView

在 NativeScript 中,我们利用执行 UI 虚拟化和**视图/组件回收的本机控件。**这意味着将仅创建可见项目的 UI 元素,并且这些 UI 元素将被回收(或重复使用)以显示新的项目。

要开始使用,ListView我们只需要从上方将基于* ngFor的模板更改为:

<ListView [items]="items">
  <ng-template let-item="item">
    <app-item [item]="item"></app-item>
  </ng-template>
</ListView>

大!快速测试表明,我们现在可以items在移动应用程序中滚动 100K 谷歌了!

ItemComponent's构造函数中的一个简单计数器显示,仅创建了 13 个实例。当您滚动时,它们将被重用于显示所有项目。

# 问题

整洁!…还是?让我们看看开始选择项目时会发生什么:

在这里,我们看到的问题实际上就是这篇文章的原因。我选择前三个项目。当我向下滚动时,也会选择项 13,14 和 15。再往下,还会选择更多我从未见过的项目。

原因是当ItemsComponents重用它们时,它们内部的状态也会被重用。以前只创建了 13 个组件,因此,如果您选择其中 3 个,则在滚动时会看到它们一次又一次弹出。

考虑一下—通过此实现,您实际上是在选择组件而不是 item。这两个集合之间不再存在 1 对 1 的关系:有 100 个(或也许是 100K😈)项目,只有 13 个ItemsComponent实例。

这是仓库中即将出现问题的分支:list-view-state-component-component 分支。

# 解决方案

有两种解决方案,但最终都归结为:

TIP

将视图状态(selected 本例中的字段)移出组件,并使组件变为无状态。

我们将使用视图状态(由于缺乏更好的术语)来获取所有信息,这些信息最初不在模型中,但仍在组件模板和应用程序逻辑中使用。在我们的情况下,该 selected 字段已提交。此信息也可能绑定到模板中的任何输入视图。

注意:想到的另一种方法是尝试在重用组件时“清洗”它们。但是,这意味着您将不可避免地失去它们的状态。根本不可能将 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 出发:

具有状态组件的 ngFor

到具有无状态组件的 ListView(尽管在 Web 版本中仍为 ngFor):

具有无状态组件的 ListView

注意: Web 模板仍使用ngFor。它与的无状态版本完美配合ItemComponent。这是仓库中的分支:list-view-state-in-model 分支。

对于简单的情况,这是一个有效的解决方案,但您可能不希望将视图状态属性与模型混合使用。或者,可能是您直接从服务中获取模型对象,并希望使它们在其他字段中保持“干净”,以便您可以在某个时候将它们发送回去。

# 将视图状态附加到项目

另一种方法是将视图状态作为单独的视图状态对象,并在 UI 中使用它时将其“附加”到模型对象。这将使我们在模型属性和视图状态属性之间有所区分,并在需要时提供一种轻松的方法来清洁模型对象。

为了使事情变得更加简单,我创建了一个 TypeScript 装饰器,它将为我完成这项工作。这是这样的:

  1. 我们 vs 使用特殊的装饰器装饰组件中专用的 view-state 属性(以下简称它)@attachViewState
  2. 我们给装饰器一个工厂函数,以为项目创建默认的视图状态对象。每当需要为项目创建视图状态对象时,它将使用它。
  3. 我们给装饰者组件中实际模型属性的名称。通常这是@Input财产-在我们的例子中是“物品”。
  4. 装饰器将创建(使用工厂)并将视图状态对象“附加”到传递给组件的每个项目中(“附加”是一种奇特的说法,它将"__vs"为该项目设置属性)。
  5. 装饰器还将更改vs属性的 getter 和 setter ,以便它们访问位于项目内部的视图状态对象。这将使在组件模板内使用视图状态更加容易。

听起来复杂吗?实际上,它很容易使用:

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 帮手获取和清洗从模型视图状态对象的方法。

const viewStateKey = '__vs';
export function attachViewState<T>(attachTo: string, defaultValueFactory?: () => T) {
  return (target: any, key: string) => {
    const assureViewState = obj => {
      if (typeof obj[attachTo][viewStateKey] === 'undefined') {
        // console.log("> creating default view sate");
        obj[attachTo][viewStateKey] = defaultValueFactory();
      }
    };
    // property getter
    var getter = function() {
      // console.log("> getter");
      assureViewState(this);
      return this[attachTo][viewStateKey];
    };
    // property setter
    var setter = function(newVal) {
      // console.log("> setter");
      assureViewState(this);
      this[attachTo][viewStateKey] = newVal;
    };
    // Delete property.
    if (delete target[key]) {
      // Create new property with getter and setter
      Object.defineProperty(target, key, {
        get: getter,
        set: setter,
        enumerable: true,
        configurable: true
      });
    }
  };
}
export function getViewState<T>(model: any): T {
  return model[viewStateKey];
}
export function cleanViewState(model: any) {
  return (model[viewStateKey] = undefined);
}

同样,代码在这里:list-view-state-in-model-decorator 分支

注意:还有其他策略。例如:

  • 在容器组件中维护视图状态对象的完整分隔列表,并将它们均作为模板的输入传递
  • 使用合成将模型项“包装”为视图模型项,从而使模型项保持完整。

# 奖金(无状态组件的情况)

值得注意的是,这些解决方案在*ngFor仍在使用的我们应用程序的 Web 版本中可以完美地工作。实际上,在许多情况下,拥有无状态组件实际上会带来更好的应用程序架构。

这是一个例子。考虑一下我们应用程序中的下一个功能:我们必须收集所有选择的项目并在不同的视图中显示(或alert暂时只显示它们 😃)。

如果“选定的”信息位于组件内部,则我们必须:

  • 使用@ViewChildren查询出这是所选项目的组件图。ew!🤮
  • 公开某种事件,以便在选择项目时通知并在容器组件中进行处理。这意味着我们将在两个不同的位置保存“选择的”信息(一次在 the 中ItemComponent,一次在容器组件中)。🤮🤮!ee!
    另一方面,如果您有一个无状态 ItemComponent并分别持有该状态,则使用数据的时间会更短。如果您从上方使用“ decorator”方法,则代码如下所示(我们使用getViewStatehelper util 中的方法来获取视图状态):
// 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 分支

# 摘要

以下是关键要点:

  1. 当切换从*ngForListView记住,这将回收在你的模板中的组件。它们内部的任何状态(@Input模板中未绑定的所有非属性)都将在回收中幸存下来,并可能导致不良行为。
  2. 考虑使用无状态(aka 表示)组件。由于所有状态都将作为输入传递,因此可以避免 1.中的问题。它还遵循了“ 智能组件与演示组件”指南,将使您的应用程序具有更好的体系结构。
  3. 奖励: 现在,使用 NativeScript 在 Web 和移动设备之间共享代码非常容易。并不是真正的话题…但是我对此很兴奋,并决定分享 😃😃😃