返回博客首页
← 所有文章

在 NativeScript ListView 中管理组件状态

2019年5月1日 — 作者:Alexander Vakrilov

本文最初发表于 Medium

一段时间前,我在博客中写过关于在 NativeScript ListView 中使用 多个项目模板 的文章,并简要介绍了UI 虚拟化视图/组件回收主题。看起来在使用 ListView 开发应用时,特别是当您在 ListView 中使用 Angular 组件作为项目并在组件中保留一些状态时,可能会遇到一些隐藏的陷阱。

我们将深入探讨这个问题,并展示一些克服它的方法。

场景

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

我们将使用一个在 Web 和移动设备之间共享代码的项目来编写此博客。原因是

  1. 我们可以概述 Web 和 NativeScript 模板之间的差异。
  2. 由于 angular-cli 和 @nativescript/schematics,现在代码共享变得异常容易。 在 Sebastian Witalec 的这篇精彩博客文章中了解更多信息 Sebastian 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

我们将首先显示模型中的所有项目,容器(又名智能)组件使用*ngFor

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

非常简单!这将为集合中的每个项目渲染一个ItemComponent

在测试项目中,生成了 100 个项目,并且 Web 和移动设备的速度都非常快。

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

Web 应用程序在 10K 个项目时开始出现明显的启动延迟。在移动项目中,阈值要低得多——大约 2K。这是因为 iOS/Android 渲染的原生组件比浏览器 DOM 元素更昂贵。如果我们使模板更复杂,这些数字将下降。

但是……没有人会在列表中放置 2000 个项目,你会说。你说得对。您可能会实现一个带有按需加载机制的无限滚动。问题是——即使那样,您也会遇到性能和内存问题,因为当您滚动时,*ngFor 会在您向下滚动和拉取更多数据时实例化越来越多的ItemComponents

这是代码,以便您可以自己尝试——只需调整 item.service.ts 以生成更多项目:ngFor 分支

我们可以做得更好!

切换到 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>

太好了!快速测试表明,我们现在可以在移动应用程序中毫无问题地滚动浏览 100K 个项目

ItemComponent构造函数中的一个简单计数器显示,永远只会创建13 个实例。它们被重复使用以显示您滚动时的所有项目。

问题

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

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

造成这种情况的原因是,当ItemsComponents被重复使用时,它们内部的状态也会被重复使用。只创建了 13 个组件,因此如果您选择其中 3 个,则在滚动时会看到它们一遍又一遍地弹出。

仔细想想——使用这种实现,您实际上选择的是组件而不是项目。这两个集合之间不再存在一对一的关系:有 100 个(或者可能是😈100K😈)项目,只有 13 个ItemsComponent实例。

以下是存储库中包含此问题的分支:list-view-state-in-component 分支

解决方案

有几个解决方案,但它们最终都归结为

将视图状态(我们示例中的selected字段)移出组件并使组件无状态。

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

注意:想到的一种替代方法是尝试在组件重复使用时“清理”它们。但是,这意味着您将不可避免地丢失它们所处的状态。将 100 个项目存储在 13 个单项目框中是不可能的。

在模型中保留视图状态

也许最容易实现的解决方案是在模型项目中添加视图状态

export interface Item {
  name: string;
  selected?: boolean;
}

您需要更改组件模板以获取/设置item中的selected字段

<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 with stateful components

到 ListView(尽管 Web 版本中仍然使用 ngFor)以及无状态组件

ListView with stateless component

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

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

将视图状态附加到项目

另一种方法是将视图状态作为单独的视图状态对象,并在 UI 中使用时将其“附加”到模型对象。这将使模型和视图状态属性之间保持一定的分隔,并提供一种在需要时清理模型对象的简单方法。

为了使事情变得更容易,我创建了一个 TypeScript 装饰器 来完成这项工作。以下是操作方法

  1. 我们使用特殊的装饰器:@attachViewState装饰组件中专用的视图状态属性(简称为vs)。
  2. 我们为装饰器提供一个工厂函数,用于为项目创建默认的视图状态对象。每当需要为项目创建视图状态对象时,它都会使用它。
  3. 我们为装饰器提供组件中实际模型属性的名称。通常是@Input属性——在我们的例子中是“item”。
  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 是视图状态对象的类型)。还有getViewStatecleanViewState辅助方法,用于从模型中获取和清理视图状态对象。

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 分支

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

  • 在容器组件中维护一个完全独立的视图状态对象列表,并将它们都作为模板的输入传递
  • 使用组合将您的模型项目“包装”到视图模型项目中,从而使模型项目完全不受影响。

额外内容(无状态组件的情况)

值得注意的是,这些解决方案在我们应用程序的 Web 版本中运行良好,其中仍然使用*ngFor。实际上,在许多情况下,使用无状态组件实际上会导致更好的应用程序架构。

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

如果“selected”信息驻留在组件内部,我们将不得不

  • 使用@ViewChildren查询组件以确定哪些是选定的项目。🤮好恶心!🤮
  • 公开某种事件以在每次选择项目时通知,并在容器组件中处理它。这意味着我们将在两个不同的位置保存“selected”信息(一次在ItemComponent中,一次在容器组件中)。🤮🤮好恶心!🤮🤮

另一方面,如果您有一个无状态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 分支

总结

以下是主要要点

  1. *ngFor切换到ListView时,请记住,它将回收模板中的组件。它们内部的任何状态(模板中未绑定的所有非@Input属性)都将保留在回收过程中,并且可能会导致意外行为。
  2. 考虑使用无状态(又名表示)组件。**它将使您免受1.中的问题,因为所有状态都将作为输入传递。它还遵循 智能组件与表示组件 指南,并将导致更好的应用程序架构。
  3. 额外内容: 使用 NativeScript 在 Web 和移动设备之间共享代码 现在变得非常容易。与主题关系不大……但我对此感到兴奋,并决定分享 😃😃😃