学习笔记(Maui 07 无限滚动)
DailyPoetryM 项目(对应 P13 部分和 P14)
本节通过实现搜索结果 ViewModelResultPage 讲解无限滚动的实现和测试
1 无限滚动的实现
1.1 基本实现
无限滚动需要使用第三方库实现,引入库 TheSalLab.MauiInfiniteScrolling (张引改写库)。无限滚动使用该库中的数据类型 MauiInfiniteScrollCollection<>,该类型与 ObservableCollection<> 功能相同且支持无限滚动。ObservableCollection<> 作为数据集合,绑定的前端可以自动获得数据并显示,但不支持无限滚动。
在中间库项目 DailyPoetryM.Library 文件夹 ViewModels 中新建类 ViewModelResultPage,定义 数据类型 MauiInfiniteScrollCollection<> 的变量 Poetries 用来保存目前显示的搜索结果,结果改变时可以自动更新页面,结果过多时可以实现无限滚动。
public class ViewModelResultPage : ObservableObject
{
private Expression<Func<Poetry, bool>> _Where;
public Expression<Func<Poetry, bool>> Where
{
get => _Where;
set => SetProperty(ref _Where, value);
}
public MauiInfiniteScrollCollection<Poetry> Poetries { get; }
public ViewModelResultPage(IStoragePoetry storagePoetry)
{
Poetries = new MauiInfiniteScrollCollection<Poetry>
{
OnCanLoadMore = () => true,
OnLoadMore = async () =>
{
return (await storagePoetry.GetPoetriesAsync(Where, Poetries.Count, PageSize)).ToList();
}
};
}
public const int PageSize = 20;
}
变量 Poetries 在构造函数中初始化。MauiInfiniteScrollCollection<> 类型的变量初始化时需要指定两个委托,一个是 OnCanLoadMore,在需要的时候调用,用来验证能否加载更多数据,返回值为 true,有更多数据,能够继续滚动;否则,停止滚动。
OnCanLoadMore = () => true
例子里的语句意味着永远有数据可用,即可以永远继续滚动。仅用作测试。
MauiInfiniteScrollCollection<> 类型的变量初始化时指定的另一个委托是 OnLoadMore,用来进行数据加载,返回加载的新数据。
(await storagePoetry.GetPoetriesAsync(Where, Poetries.Count, PageSize)).ToList();
Where 属性是搜索的条件。Poetries.Count 是跳过已经显示的诗词数量,PageSize 是每次读取的诗词数量,即每次读取返回的诗词个数。ToList() 将枚举类型转换为列表类型,为了防止枚举类型枚举次数的限制的可能性。枚举类型没有属性能够直接给出元素个数,想获得元素个数只能通过枚举行为将元素取出再获得。枚举类型可能可重复枚举,也有可能只能枚举一次,一旦出现后者的情况,则只能获取一次元素个数。
1.2 _CanLoadMore 变量
定义变量 _CanLoadMore 表示“能否加载更多数据”的判断。能否加载更多数据的判断由 OnCanLoadMore 返回,其判断由 OnLoadMore 做出。
public class ViewModelResultPage : ObservableObject
{
private Expression<Func<Poetry, bool>> _Where;
public Expression<Func<Poetry, bool>> Where
{
get => _Where;
set => SetProperty(ref _Where, value);
}
public MauiInfiniteScrollCollection<Poetry> Poetries { get; }
public ViewModelResultPage(IStoragePoetry storagePoetry)
{
Poetries = new MauiInfiniteScrollCollection<Poetry>
{
OnCanLoadMore = () => _CanLoadMore;
OnLoadMore = async () =>
{
Status = Loading;
var poetries = (await storagePoetry.GetPoetriesAsync(Where, Poetries.Count, PageSize)).ToList();
if (poetries.Count < PageSize)
{
_CanLoadMore = false;
}
if (poetries.Count == 0 && Poetries.Count == 0)
{
_CanLoadMore = false;
}
return poetries;
}
};
}
private bool _CanLoadMore;
public const int PageSize = 20;
}
poetries.Count 为从数据库读出的诗词数量,如果其值小于 PageSize,说明数据库没有更多的诗词数据,所有搜索到的诗词已经展示了,即不能提供更多数据了,无限滚动停止。如果已经读出的诗词数量 Poetries.Count 为 0,并且数据库中能读出的诗词数量也为 0,说明搜索没有获得任何诗词,不能提供数据了,无限滚动也停止了。
1.3 新的搜索条件对变量 _CanLoadMore 的影响
除了在构造变量 Poetries 时,进行“能否加载更多数据”的判断,在获得新的搜索条件时也应改进行“能否加载更多数据”的判断。
private Expression<Func<Poetry, bool>> _Where;
public Expression<Func<Poetry, bool>> Where
{
get => _Where;
set
{
if (value != _Where)
{
_CanLoadMore = true;
}
SetProperty(ref _Where, value);
}
}
如果条件改变,则说明有新的搜索需求出现,可以认为能偶加载更多数据。考虑到函数 SetProperty 的返回值,可以简写。
private Expression<Func<Poetry, bool>> _Where;
public Expression<Func<Poetry, bool>> Where
{
get => _Where;
set => _CanLoadMore = SetProperty(ref _Where, value);
}
1.4 读取数据的状态
定义属性 Status 表示读取数据读取状态。
public class ViewModelResultPage : ObservableObject
{
private Expression<Func<Poetry, bool>> _Where;
public Expression<Func<Poetry, bool>> Where
{
get => _Where;
set => _CanLoadMore = SetProperty(ref _Where, value);
}
public MauiInfiniteScrollCollection<Poetry> Poetries { get; }
private string _Status;
public string Status
{
get => _Status;
set => SetProperty(ref _Status, value);
}
public ViewModelResultPage(IStoragePoetry storagePoetry)
{
Poetries = new MauiInfiniteScrollCollection<Poetry>
{
OnCanLoadMore = () => _CanLoadMore;
OnLoadMore = async () =>
{
Status = Loading;
var poetries = (await storagePoetry.GetPoetriesAsync(Where, Poetries.Count, PageSize)).ToList();
Status = string.Empty;
if (poetries.Count < PageSize)
{
_CanLoadMore = false;
Status = NoMoreResult;
}
if (poetries.Count == 0 && Poetries.Count == 0)
{
_CanLoadMore = false;
Status = NoResult;
}
return poetries;
}
};
}
private RelayCommand _CommandNavigatedTo;
public RelayCommand CommandNavigatedTo => _CommandNavigatedTo ??= new RelayCommand(async () =>
{
Poetries.Clear();
await Poetries.LoadMoreAsync();
});
private bool _CanLoadMore;
public const int PageSize = 20;
public const string Loading = "正在载入";
public const string NoResult = "没有满足条件的结果";
public const string NoMoreResult = "没有更多结果";
}
1.5 数据的第一次加载
private RelayCommand _CommandNavigatedTo;
public RelayCommand CommandNavigatedTo => _CommandNavigatedTo ??= new RelayCommand(async () =>
{
Poetries.Clear();
await Poetries.LoadMoreAsync();
});
每当导航到搜索结构页面时需要加载数据,命令 CommandNavigatedTo 在导航到搜索结果页面时执行。
2 单元测试
2.1 Poetries 测试
测试无限滚动功能。在测试项目 DailyPoetryM.TestProject 中创建文件夹 ViewModels,添加类 ViewModelResultPageTest,添加测试函数 Poetries_Default()。
[Fact]
public async Task Poetries_Defaullt()
{
var Where = Expression.Lambda<Func<Poetry, bool>>(
Expression.Constant(true),
Expression.Parameter(typeof(Poetry), "p"));
var storagePoetry = await StoragePoetryTest.GetInitializedStoragePoetry();
var viewModelResultPage = new ViewModelResultPage(storagePoetry);
viewModelResultPage.Where = Where;
viewModelResultPage.CommandNavigatedTo.Execute(null);
Assert.Equal(ViewModelResultPage.PageSize, viewModelResultPage.Poetries.Count);
await storagePoetry.CloseAsync();
}
Lambda 表达式 Expression.Lambda 是没有名字的函数。函数的具体内容由 Func 描述。Func< Poetry, bool> 表示一个参数为 Poetry,返回值为 bool 的函数,函数的函数体定义 Expression.Constant(true),表示常量 true,即该函数返回常量 true。表示每一条数据都满足要求,数据库中所有数据都可以返回;函数的参数定义 Expression.Parameter(typeof(Poetry), “p”),参数的名字为 p,参数的类型为 Poetry。
Expression.Lambda 语句为数据库取值提供查询条件,也可以使用类似 SQL 语句的 LINQ(language integreted querey,LINQ)语句实现。
var pList = new List<Poetry>{new Poetry { Id = 1 }, new Poetry { Id = 2 }, new Poetry { Id = 3 }};
var poetriesWithIdGt1 = from p in pList where p.Id > 1 select p;
poetriesWithIdGt1 包含 id=2 和 3 的诗歌。LINQ 语句表示根据 where 提供的搜索条件(Id > 1) 对 pList 中的每一个元素(诗歌)进行判断,并选择所有满足条件的元素(诗歌)。现代 LINQ 语句更加简洁。
var poetriesWithIdGt2 = pList.Where(p => p.Id > 1);
使用 LINQ 语句和 Expression.Lambda 可以获得相同的效果,但是 LINQ 语句在编译时就锁定了条件,不能动态改动查询条件。而 Expression.Lambda 可以方便地改变查询条件。
主动调用 RelayCommand时需要使用 Execute。
测试失败,ViewModelResultPage.PageSize 和 viewModelResultPage.Poetries.Count 不相等。
2.2 RelayCommand 的测试
测试失败的原因是 Execute 执行时,会新开一个新线程执行程序,将原来的单线程执行变成多线程执行。测试时,程序并行执行,Assert 时读取数据库操作并没有完成。解决方案是将 RelayCommand 的内容剥离出来。
private RelayCommand _CommandNavigatedTo;
public RelayCommand CommandNavigatedTo => _CommandNavigatedTo ??= new RelayCommand(async () =>
{
await CommandNaviagedToFunction();
});
public async Task CommandNaviagedToFunction()
{
Poetries.Clear();
await Poetries.LoadMoreAsync();
}
在中间库项目 DailyPoetryM.Library 类 ViewModelResultPage 中,添加的函数 CommandNaviagedToFunction()负责将 RelayCommand CommandNavigatedTo 的内容剥离出来,方便测试。
在测试项目 DailyPoetryM.TestProject 中类 ViewModelResultPageTest 的函数 Poetries_Default()中添加语句。
await viewModelResultPage.CommandNaviagedToFunction();
代替 RelayCommand 命令的 Execute 调用。
测试通过。
2.3 属性 Status 的测试
属性 Status 在程序执行过程中会多次变化,需要测试属性变化的中间值。所以采用 List< string> 存储其在程序执行过程中出现的所有值历史值。并且采用属性变化事件触发其改变进行测试。
var listStatus = new List<string>();
viewModelResultPage.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(ViewModelResultPage.Status))
{
listStatus.Add(viewModelResultPage.Status);
}
}
Assert.Equal(2, listStatus.Count);
Assert.Equal(ViewModelResultPage.Loading, listStatus[0]);
Assert.Equal(string.Empty, listStatus[1]);
先测试的是属性变化次数,根据代码应该变化两次,然后分别判断两次的值。测试通过。
最终的测试函数
[Fact]
public async Task Poetries_Defaullt()
{
var Where = Expression.Lambda<Func<Poetry, bool>>(
Expression.Constant(true),
Expression.Parameter(typeof(Poetry), "p"));
var storagePoetry = await StoragePoetryTest.GetInitializedStoragePoetry();
var viewModelResultPage = new ViewModelResultPage(storagePoetry);
viewModelResultPage.Where = Where;
var listStatus = new List<string>();
viewModelResultPage.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(ViewModelResultPage.Status))
{
listStatus.Add(viewModelResultPage.Status);
}
};
await viewModelResultPage.CommandNaviagedToFunction();
Assert.Equal(ViewModelResultPage.PageSize, viewModelResultPage.Poetries.Count);
Assert.Equal(2, listStatus.Count);
Assert.Equal(ViewModelResultPage.Loading, listStatus[0]);
Assert.Equal(string.Empty, listStatus[1]);
await storagePoetry.CloseAsync();
}
2.4 单元测试小结
单元测试遇到不能测的内容
- 项目不能测试:剥离到能测试的类测试
- 类不能测试:封装成接口用 mock 测试
- RelayCommand:将内部代码剥离到函数测试