PAYATHON 2026

.NET Maui 中绑定值变化后,Entry 字段为何不更新?

排障赵师傅

问题主要出在缺少属性变更通知。HomePageViewModel 虽然继承了 ObservableObject,普通自动属性却不会因此自动发送通知;Patient 中的 Lastname、Id 等属性也没有实现通知机制。

姓名字段还绑定了整个 MyPatient 对象。即使显示时调用了 ToString(),绑定系统也无法据此判断姓名依赖哪些属性。需要将它绑定到具体的姓名属性,并在相关数据变化时发送通知。

为什么初始值能显示,后续修改却没有反应?

绑定建立时会读取当前值,所以启动时的患者信息可以正常显示。后续值发生变化时,需要通过 INotifyPropertyChanged.PropertyChanged 通知绑定系统重新读取。

替换患者对象和修改对象内部的属性,需要分别处理:

  • MyPatient = anotherPatient:由 HomePageViewModel 通知 MyPatient 已更换。
  • MyPatient.Id = 2:由当前 Patient 对象通知 Id 已改变。

原代码在这两个层级都使用了普通自动属性,因此没有发出相应通知。x:DataType 用于编译绑定的类型检查,不会替属性实现变更通知。

1. 让 Patient 的属性发送通知

项目已经使用 CommunityToolkit.Mvvm,可以用 ObservableObject 和 SetProperty 实现通知,再增加一个 FullName 属性,保留原来“姓, 名”的显示格式:

using CommunityToolkit.Mvvm.ComponentModel;

namespace Aequus.CoreBusiness;

public class Patient : ObservableObject
{
    private int id;
    private string lastname = string.Empty;
    private string firstname = string.Empty;

    public int Id
    {
        get => id;
        set => SetProperty(ref id, value);
    }

    public string Lastname
    {
        get => lastname;
        set
        {
            if (SetProperty(ref lastname, value))
            {
                OnPropertyChanged(nameof(FullName));
            }
        }
    }

    public string Firstname
    {
        get => firstname;
        set
        {
            if (SetProperty(ref firstname, value))
            {
                OnPropertyChanged(nameof(FullName));
            }
        }
    }

    public string FullName => $"{Lastname}, {Firstname}";

    public override string ToString() => FullName;
}

SetProperty 会比较新旧值,在值发生变化时发送对应属性的 PropertyChanged 事件。FullName 依赖 Lastname 和 Firstname,所以这两个属性变化时,还要发送 FullName 的变更通知。

使用这段代码时,Aequus.CoreBusiness 项目需要引用 CommunityToolkit.Mvvm。如果不希望业务层依赖该包,可以自行实现 INotifyPropertyChanged,也可以在界面层增加支持属性变更通知的患者 ViewModel。

2. 让 MyPatient 的替换也能通知界面

MyPatient 同样需要通过 SetProperty 赋值,这样异步加载完成后,界面才能读取新的患者对象:

using Aequus.CoreBusiness;
using Aequus.UseCases.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel;
using DicomObjects;
using Microsoft.Maui.Controls;

namespace Aequus.Maui.ViewModels;

public class HomePageViewModel : ObservableObject, IQueryAttributable
{
    private Patient? myPatient;
    private DicomImage? currentDicomImage;

    public Patient? MyPatient
    {
        get => myPatient;
        set => SetProperty(ref myPatient, value);
    }

    public ILoadPatientUseCase LoadPatientUseCase { get; }

    public HomePageViewModel(ILoadPatientUseCase loadPatientUseCase)
    {
        LoadPatientUseCase = loadPatientUseCase;
    }

    public async Task LoadPatientAsync()
    {
        MyPatient = await LoadPatientUseCase.ExecuteAsync();
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (!query.TryGetValue("ReturnedObject", out var obj))
        {
            return;
        }

        // 保留原问题的测试逻辑,允许返回值暂时为 null。
        currentDicomImage = obj as DicomImage;

        if (MyPatient is null)
        {
            return;
        }

        MyPatient.Lastname = "Peters";
        MyPatient.Id = 2;
    }
}

修改后,MyPatient.Lastname 和 MyPatient.Id 的 setter 会发送通知,每次赋值后都不必再手动刷新页面。

这里的两条赋值仍用于测试。实际导入时,应根据返回的图像更新患者数据,并按业务要求检查 obj 是否为有效的 DicomImage。

3. 姓名绑定到 FullName,ID 绑定到 Id

如果两个字段只用于显示,可以改为:

<Entry
    Grid.Row="0"
    Grid.Column="1"
    IsReadOnly="True"
    Text="{Binding MyPatient.FullName, Mode=OneWay}" />

<Entry
    Grid.Row="1"
    Grid.Column="1"
    IsReadOnly="True"
    Text="{Binding MyPatient.Id, Mode=OneWay}" />

修改 Lastname 时,FullName 的变更通知会让第一个字段更新;修改 Id 时,第二个字段会读取新的 ID。

如果姓名字段只需要显示姓,可以直接绑定:

<Entry
    Text="{Binding MyPatient.Lastname, Mode=TwoWay}" />

FullName 是只读计算属性,适合展示。要编辑完整姓名,应分别绑定 Lastname 和 Firstname,或提供明确的姓名编辑逻辑。整数 ID 如果允许编辑,还需要处理空字符串和无效输入;Keyboard="Numeric" 不能代替数据校验。

4. 从 ImportPage 返回时只执行一次返回导航

原代码先导航到绝对路由:

await Shell.Current.GoToAsync($"//{nameof(HomePage)}");

随后又执行:

await Shell.Current.GoToAsync("..", navigationParameter);

第一次导航已经改变了当前页面和导航栈,第二次的 .. 就不再表示“从 ImportPage 返回 HomePage”。具体结果取决于 Shell 路由结构。

如果 ImportPage 是从 HomePage 压入导航栈的,返回并传参只需一次操作:

private async void btnImport_Clicked(object sender, EventArgs e)
{
    if (currentDicomImage is null)
    {
        return;
    }

    var navigationParameter = new Dictionary<string, object>
    {
        ["ReturnedObject"] = currentDicomImage
    };

    await Shell.Current.GoToAsync("..", navigationParameter);
}

private async void btnCancel_Clicked(object sender, EventArgs e)
{
    await Shell.Current.GoToAsync("..");
}

导入成功后返回对象,取消时直接返回,两种操作分别处理。

如果只是测试通知机制,还没有选择图像,这段导入代码会因为 currentDicomImage 为 null 而直接返回。可以先在患者加载完成后临时执行两条测试赋值,确认绑定能否更新。

5. 单独处理异步初始化

原构造函数调用了 LoadPatientAsync(),却没有等待它完成。这样难以控制执行顺序和处理加载异常,也可能在收到返回参数时,MyPatient 还没有加载完成。

前面的 ViewModel 已移除构造函数中的异步调用,需要在页面初始化时显式等待加载完成。可以保留现有的 BindingContext 设置,在 HomePage 中增加:

private bool isLoading;
private bool isLoaded;

protected override async void OnAppearing()
{
    base.OnAppearing();

    if (isLoading || isLoaded)
    {
        return;
    }

    isLoading = true;

    try
    {
        await homePageViewModel.LoadPatientAsync();
        isLoaded = true;
    }
    catch (Exception ex)
    {
        await DisplayAlert("加载失败", ex.Message, "OK");
    }
    finally
    {
        isLoading = false;
    }
}

private async void ImportToolBar_Clicked(object sender, EventArgs e)
{
    if (!isLoaded)
    {
        return;
    }

    await Shell.Current.GoToAsync(nameof(ImportPage));
}

返回页面时,OnAppearing 还会执行。这里用 isLoaded 避免重复加载,以免覆盖刚导入的数据。实际界面也可以在加载期间禁用导入按钮。

如果修改后仍显示旧值,先检查 ApplyQueryAttributes 是否收到了参数,再确认它所属的 ViewModel 就是当前页面的 BindingContext。为已有对象补发 OnPropertyChanged(nameof(MyPatient)) 可以帮助诊断,或临时让绑定重新求值。持续修改嵌套属性时,应由 Patient 自身发送对应属性的变更通知。

备注:内容仅供参考。