本文最初发表于 Medium。
一段时间前,我在博客中写过关于在 NativeScript ListView 中使用 多个项目模板 的文章,并简要介绍了UI 虚拟化和视图/组件回收主题。看起来在使用 ListView 开发应用时,特别是当您在 ListView 中使用 Angular 组件作为项目并在组件中保留一些状态时,可能会遇到一些隐藏的陷阱。
我们将深入探讨这个问题,并展示一些克服它的方法。
为了演示这个问题,我们将构建一个显示项目列表的应用程序,并且我们希望能够选择其中的一些项目。
我们将使用一个在 Web 和移动设备之间共享代码的项目来编写此博客。原因是
以下是该应用程序在浏览器和 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
<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 分支。
我们可以做得更好!
在 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
到 ListView(尽管 Web 版本中仍然使用 ngFor)以及无状态组件
注意:Web 模板仍然使用ngFor
。它与ItemComponent
的无状态版本完美配合。以下是存储库中的分支:list-view-state-in-model 分支。
对于简单的情况,这是一个有效的解决方案,但您可能不想将视图状态属性与模型混合使用。或者,您可能直接从服务获取模型对象,并且希望将其保持“干净”,以便以后可以将其发送回去。
另一种方法是将视图状态作为单独的视图状态对象,并在 UI 中使用时将其“附加”到模型对象。这将使模型和视图状态属性之间保持一定的分隔,并提供一种在需要时清理模型对象的简单方法。
为了使事情变得更容易,我创建了一个 TypeScript 装饰器 来完成这项工作。以下是操作方法
@attachViewState
装饰组件中专用的视图状态属性(简称为vs
)。@Input
属性——在我们的例子中是“item”。"__vs"
属性的一种花哨方式)。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 分支
注意:还有其他策略。例如
值得注意的是,这些解决方案在我们应用程序的 Web 版本中运行良好,其中仍然使用*ngFor
。实际上,在许多情况下,使用无状态组件实际上会导致更好的应用程序架构。
举个例子。考虑我们应用程序中的下一个功能:我们必须收集所有选定的项目并在不同的视图中显示(或者只是alert
它们 😃)。
如果“selected”信息驻留在组件内部,我们将不得不
@ViewChildren
查询组件以确定哪些是选定的项目。🤮好恶心!🤮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 分支
以下是主要要点
*ngFor
切换到ListView
时,请记住,它将回收模板中的组件。它们内部的任何状态(模板中未绑定的所有非@Input
属性)都将保留在回收过程中,并且可能会导致意外行为。