技术文章

使用 MATLAB 面向对象编程创建专用图表

作者 Ken Deeley and David Sampson, MathWorks


开发高级 MATLAB® 可视化通常需要管理多个低级图形对象。这在包含动态更新图形的应用程序开发中尤为常见。这类应用程序的编程可能相当耗时。

图表对象可提供高级应用程序编程接口 (API),用于创建自定义可视化。图表不仅为最终用户提供了便捷的可视化 API,也免去了实现低级图形编程的需要。

MATLAB 包含一个面向对象的框架,用于通过以下容器超类开发自定义图:

本文提供了使用此框架创建和实现自定义图表的分步指南,包括设计模式和最佳实践。下文将以包含最佳拟合线的散点图为例,对这些步骤加以说明。您将了解到如何:

  • 编写标准图表模板
  • 编写图表的 setupupdate 方法
  • 封装数据和图形
  • 为最终用户提供高级 API
  • 加入交互式控件

图表示例

MATLAB 提供多种图表,包括 heatmap 热图和 geobubble 地理气泡图,前者用彩色矩形网格可视化矩阵值,后者可在地图上快速绘制离散数据点(图 1)。

图 1.heatmap 热图和 geobubble 地理气泡图。

图 1.heatmap 热图和 geobubble 地理气泡图。

另外,MATLAB 还针对特定应用创建了专用图表(图 2)。您可以从 File Exchange 下载这些图表以及本文使用的 MATLAB 代码。

图 2.可以从 File Exchange 下载自定义图表。

图 2.可以从 File Exchange 下载自定义图表。

创建二维散点图

假设我们要创建一个二维散点图,其中包含相应的最佳拟合线(图 3)。我们可以使用 Statistics and Machine Learning Toolbox™ 中的 scatter 函数对离散 (x,y) 数据点进行可视化,使用 fitlm 函数计算最佳拟合线。

 rng("default") x = randn(1000, 1); y = 2*x + 1 + randn(size(x)); s = scatter(x, y, 6, "filled", "MarkerFaceAlpha", 0.5); m = fitlm(x, y); hold on plot(x, m.Fitted, "LineWidth", 2) 
图 3.最佳拟合线和背后的散点数据。

图 3.最佳拟合线和背后的散点数据。

以上代码可以满足静态可视化的需求。不过,如果应用程序要求数据支持动态修改,那么我们会遇到几个难题:

  • 如果我们将 XDataYData 替换成一个与当前 XData 长度相同的新数组,最佳拟合线并不会动态更新(图 4)。
     s.XData = s.XData + 4; 
图 4.更改散点图的 XData 后,最佳拟合线未更新。

图 4.更改散点图的 XData 后,最佳拟合线未更新。

  • 如果将散点图 (Scatter) 对象 s 的任一数据属性(XDataYData)设置为长于或短于当前数组的新数组,该对象会发出警告,且不执行图形更新。
     s.XData = s.XData(1:500);
    

要解决以上难题,我们可以设计一个名为 ScatterFit 的图表。

构造图表代码:函数还是类?

函数将代码封装为可重用单元,您无需复制代码即可创建多个图表。

function scatterfit(varargin) 

% 确保输入为 2 个或 3 个。 
narginchk(2, 3) 

% 支持使用语法 scatterfit(x, y) 或 
% scatterfit(f, x, y) 进行调用,其中 f 是父图形。 
switch nargin 
   case 2 
      f = gcf; 
      x = varargin{1}; 
      y = varargin{2}; 
   otherwise % 即 case 3 
      f = varargin{1}; 
      x = varargin{2}; 
      y = varargin{3}; 
end % switch/case 结束 

% 创建图表坐标区和散点图。 
ax = axes("Parent", f); 
scatter(ax, x, y, 6, "filled") 
% 计算和创建最佳拟合线。 
m = fitlm(x, y); 
hold(ax, "on") 
plot(ax, x, m.Fitted, "LineWidth", 2) 
hold(ax, "off") 

end % scatterfit 函数结束

可以看到,此函数需要两个数据输入(xy)。此外,您可以指定图形父项 f(例如,一个图窗)作为第一个输入参数。

  • scatterfit(x, y) 指定两个数据输入。
  • scatterfit(f, x, y) 指定图形的父项和数据。

在第一种情况下,该函数会呈现自动父级 (autoparenting) 行为,即会自动为该图表创建图窗。

使用函数创建图表有一些缺点:

  • 创建图表后,无法修改数据。
  • 要更改图表数据,您需要再次调用该函数,以重新创建该图表。
  • 最终用户很难找到可配置的图表参数(例如标签,以及颜色、线型等装饰性的图形属性)。

如果用类来实现图表,您一样可以享受代码封装和可重用性带来的种种便利,而且无需重新创建图表即可进行修改。

选择图表超类:ChartContainer 还是 ComponentContainer

为了直接在原图表上进行修改,必须用句柄 (handle) 类来实现图表。为了与 MATLAB 图形对象保持一致,除了标准圆点表示法之外,图表还应支持属性的 get/set 语法。ChartContainerComponentContainer 均为句柄类,支持 get/set 语法,这意味着您可以从这些超类派生自定义图表。

 classdef ScatterFit < matlab.ui.componentcontainer.ComponentContainer

该图表的任何属性均自动支持表 1 列出的语法。

语法类型 访问 修改
圆点表示法 x = SF.XData; SF.XData = x;
get/set x = get(SF, "XData"); set(SF, "XData", x)

表 1.图表属性的访问语法和修改语法。

根据图表的具体需求选择一个超类。如果图表不需要面向用户的交互式控件,例如按钮、下拉菜单和复选框,则从 ChartContainer 派生图表;否则,使用 ComponentContainer。这是因为图表容器超类的顶层图形对象是一个分块布局,此对象可以包含坐标区,但不能包含用户控件。与组件容器关联的顶层图形对象则是一个类似面板的对象,同时支持坐标区和用户控件。

请注意,框架超类会自动管理图表生命周期,保证以下行为:

  • 当图表图形被删除时(例如,通过关闭主图窗窗口),图表对象也会被删除。
  • 当图表对象被删除时(例如,当它超出范围或当它的句柄被删除时),图表图形也会被删除。

对于所有图表输入参数,框架超类支持使用名称-值对组。这意味着在创建图表时不需要指定输入参数,所有输入都是可选的。

编写图表 setupupdate 方法

我们现在需要实现两个特殊方法,这两个方法都是框架超类所需要的:

  • setup:在创建图表时自动调用
  • update:在用户修改某些图表属性时自动调用

这些方法在我们的图表类中必须具有受保护的访问权限,因为它们在超类中具有此属性。

methods (Access = protected) 

    function setup(obj) 

    end % setup 结束 

    function update(obj) 

    end % update 结束 

end % methods (Access = protected) 结束

先考虑 setup 方法。该方法是类定义中的函数,我们将使用该类定义初始化图表。我们不妨先将 scatterfit 函数中的代码复制到 setup 方法中。然后,我们进行以下修改,以实现所需的图表行为:

  • 父图形。不同于前文 scatterfit 函数中所述的方法,如果未指定 Parent 输入,就不会为图表自动创建该输入。可以看到,此行为不同于 plotscatter 等便利函数所表现出的自动父级行为。在 setup 方法中,我们创建主图形对象(例如坐标区、面板或布局),并将其父属性指定给超类提供的顶层图形对象。对于 ChartContainer 超类,需要使用 getLayout 方法返回对顶层分块布局的引用。对于 ComponentContainer 图表,只需将图形父属性指定给对象本身。

    obj.Axes = axes("Parent", obj.getLayout()); % 使用 ChartContainer 
    obj.Axes = axes("Parent", obj); % 使用 ComponentContainer
    

    如果将 Parent 指定为输入参数,那么它将由超类自动设置,和图表创建期间提供的任何其他名称-值对组一样。超类会将用户指定的父项指定为顶层图形对象的父项。

  • 图表图形。我们创建并存储图表所需的所有图形对象。大多数图表都需要一个坐标区对象以及一些坐标区内容,例如线条或补片对象。在 ScatterFit 图表中,我们需要散点图 (Scatter) 对象和线条 (Line) 对象。

    obj.ScatterSeries = scatter(obj.Axes, NaN, NaN); 
    obj.BestFitLine = line(obj.Axes, NaN, NaN);
    

    可以看到,初始化这些图形时,其数据属性设置为 NaN。如果用户在构造时指定了 XData 和/或 YData,则散点图和最佳拟合线将稍后在其相应的 set 方法中更新(稍后讨论)。在编程中采用这一做法,可以确保捕捉到用户指定名称-值对组时引起的任何错误,并单独加以处理。

  • 图形配置。我们通过设置所有必需的属性来配置图表图形。例如,我们可以创建标签或标题等注释,设置坐标区的特定视图,添加网格,或调整线条的颜色、样式或宽度。

只要可行,我们会使用基本对象(表 2)来创建图表图形,因为调用高级便利函数会重置许多现有的坐标区属性。不过,这一原则也有例外:在 ScatterFit 中,我们使用非基本函数 scatter 来创建图形对象,因为它支持后续对单个标记大小和颜色进行更改(而单个线条对象则不支持)。

基本图形函数 高级图形函数
line plot
surface surf
patch fill

表 2.基本和高级图形函数的示例。

我们稍后会继续介绍图表的 update 方法。

封装图表数据和图形

在大多数图表中,底层图形包含至少一个坐标区对象及其内容(例如线条或曲面对象)或坐标区对等对象(例如图例或颜色栏)。该图表还具有内部数据属性,以确保公共属性能正确地呈现给最终用户。我们将底层图形和内部数据存储为私有图表属性。例如,ScatterFit 图表具有以下私有属性。

properties (Access = private) 
        % XData 属性的内部存储。
        XData_ = double.empty(0, 1) 
        % YData 属性的内部存储。
        YData_ = double.empty(0, 1) 
        % 逻辑标量,指定是否需要计算。
        ComputationRequired = false() 
end % properties (Access = private) 结束

我们使用命名约定 XData_ 来表示这是图表数据的私有内部版本。对用户可见的相应公共数据属性将命名为 XData

properties (Access = private, Transient, NonCopyable) 
        % 图表坐标区。
        Axes(1, 1) matlab.graphics.axis.Axes 
        % (x, y) 数据的散点序列。
        ScatterSeries(1, 1) matlab.graphics.chart.primitive.Scatter 
        % 最佳拟合线的线条对象。
        BestFitLine(1, 1) matlab.graphics.primitive.Line 
end % properties (Access = private, Transient, NonCopyable) 结束

对内部图表数据和图形使用私有 (private) 属性有三个主要目的。

  • 私有属性限制了低级图形的可见性,隐藏了实现细节,减少了图表 API 中的视觉干扰。
  • 对低级图形的访问受到限制,减少了绕过 API 的机会。
  • 图表数据可以轻松地同步(例如,我们要求关联 ScatterFitXDataYData 属性)。

对于内部图形属性,我们推荐指定 TransientNonCopyable 属性。这样一来,我们可以确保图表对象在保存到 MAT 文件时或在复制过程中行为正确。为了增加稳健性,并且为了在使用图表类时对图形属性启用 Tab 键自动填充功能,我们还实现了属性验证

提供可视化 API

设计图表的主要目的之一是提供方便、直观的 API。我们使用与现有图形对象属性一致的名称,为 ScatterFit 图表设置了易于识别的属性(图 5)。

图 5.ScatterFit 图表 API。

图 5. ScatterFit 图表 API。

用户可以使用表 1 中的示例语法访问或修改这些属性。相关的图表图形会随着属性的修改而动态更新。例如,更改图表的 LineWidth 属性会更新最佳拟合线的 LineWidth

我们使用 Dependent 属性实现图表 API 的某些部分。Dependent 属性的值并不显式存储,而是派生自类中的其他属性。在图表中,Dependent 属性依赖于私有属性,如低级图形或内部数据属性。

要定义 Dependent 属性,我们首先在具有 Dependent 属性的属性块中声明属性名称。这表明该属性的值取决于该类中的其他属性。

properties (Dependent) 
    % 图表 x-data。
    XData(:, 1) double {mustBeReal} 
    % 图表 y-data。
    YData(:, 1) double {mustBeReal} 
end % properties (Dependent) 结束

我们还需要通过编写相应的 get 方法来指定该属性如何依赖于其他类属性。此方法会返回一个输出参数,即 Dependent 属性的值。在 ScatterFit 图表中,图表的 XData 属性(图表公共接口的一部分)就是底层的 XData_ 属性,后者作为图表的私有属性存储在内部。

function value = get.XData(obj) 

    value = obj.XData_; 

end % get.XData 结束

每个数据属性还必须具有 set 方法。该方法将用户指定的值指定给正确的内部图表属性,并触发任何必要的图形更新。

对于 ScatterFit 图表,我们支持对数据属性(XDataYData)进行动态修改(包括长度更改)。当用户设置图表的(公共)XData 时,我们将新数据向量的长度与现有数据进行比较,新数据长,则对相应的(私有)数据属性 YData_ 进行填充,反之则进行截断。之前提到,如果用户在创建图表时指定了 XData,则将在构造时调用这个 set 方法。

function set.XData(obj, value) 

    % 标记图表需要更新。 
    obj.ComputationRequired = true(); 

    % 确定如何修改图表数据。 
    nX = numel(value); 
    nY = numel(obj.YData_); 

    if nX < nY % 如果新的 x-data 太短,则截断图表 y-data。 
        obj.YData_ = obj.YData_(1:nX); 
    else 
        % 否则,如果 nX >= nY,则填充 y-data。 
        obj.YData_(end+1:nX, 1) = NaN; 
    end % if 结束 

    % 设置内部 x-data。 
    obj.XData_ = value; 

end % set.XData 结束

请注意,每当用户设置一个公共属性,图表的 update 方法就会自动调用。为了避免不必要的耗时计算,我们使用一个私有的内部逻辑属性 ComputationRequiredset 方法中记录是否有必要进行完全更新。

公共 API 属性如果在更改后不需要新的计算,则不需要 getset 方法。这种情况下,我们只需在 update 方法结束时刷新相应的内部对象。通常情况下,公共 API 属性包括颜色、线宽、样式等图表装饰和外观元素,更新这些属性的计算成本很低。

ScatterFit 图表中,update 方法包含必要的代码,可在散点图 (Scatter) 对象中设置新数据,重新计算最佳拟合线,并在相应的线条 (Line) 对象中设置新数据。

function update( obj ) 

    if obj.ComputationRequired 

    % 使用新数据更新散点序列。 
    set(obj.ScatterSeries, "XData", obj.XData_, "YData", obj.YData_) 

    % 获取新的最佳拟合线。 
    m = fitlm(obj.XData_, obj.YData_); 

    % 更新最佳拟合线图形 
    [~, posMin] = min(obj.XData_); 
    [~, posMax] = max(obj.XData_); 
    set(obj.BestFitLine, "XData", obj.XData_([posMin, posMax]), "YData", m.Fitted([posMin, posMax])) 

    % 标记图表完成更新。 
    obj.ComputationRequired = false(); 

    end % if 结束 

    % 刷新图表的装饰属性。 
    set(obj.ScatterSeries, "CData", obj.CData, "SizeData", obj.SizeData) 

end % update 结束

我们可以用同样的方式实现 YDataset 方法,只需在代码中将 X/YData 属性互换即可。

为了创建一个适合最终用户的、功能丰富的 API,我们实现了大量公共属性。请注意,ParentPositionUnitsVisible 等标准属性都是从超类继承而来,不需要在图表中额外实现。

添加图表注释方法

在该 API 中,我们提供了友好、易用的方法来注释图表。这些注释方法会重载相应的高级图形装饰函数,即与之同名。要使用这些方法,第一个输入参数应该是对图表的引用,然后是对装饰函数的输入。

 xlabel(SF, "x-data", "FontSize", 12)

如果装饰函数支持,也可以在调用注释方法时提供一个输出,以返回对图形对象的引用,用于进一步自定义。例如,xlabel 函数返回一个文本对象。

 xl = xlabel(SF, "x-data");

要支持名称-值对组和输出参数,比较便捷的方法是使用元胞数组 vararginvarargout。语法 varargin{:} 生成一个以逗号分隔的输入参数列表。我们使用 nargout 确定调用方的输出个数。为了处理可变数量的输出参数(通常,这些方法的输出参数为 0 个或 1 个),我们在调用装饰函数时会使用语法 [varargout{1:nargout}]。典型的注释方法结构如下:

function varargout = xlabel(obj, varargin) 

    [varargout{1:nargout}] = xlabel(obj.Axes, varargin{:}); 

end % xlabel 结束

在图表中加入交互式控件

除了图表的 API 之外,我们还可以加入控件,为最终用户提供图表交互和修改选项(图 6)。

图 6.交互式图表控件示例。

图 6.交互式图表控件示例。

我们使用 App 构建组件在图表 setup 方法中初始化这些控件。每个控件都有一个回调函数,以私有方法实现。此方法具有三个输入参数:

  • 图表对象。
  • 对负责触发回调的对象的引用。在本示例中,源对象是相应的用户控件。
  • 事件数据。用户与控件交互时由 MATLAB 自动传递给回调函数的对象。事件数据对象包含有关事件的额外信息。

以控制最佳拟合线可见性的复选框的回调函数为例。该函数会根据复选框的值来切换底层线条对象的可见性。

function toggleLineVisibility(obj, s, ~) 
%TOGGLELINEVISIBILITY 切换最佳拟合线的可见性。 

    obj.BestFitLine.Visible = s.Value; 

end % toggleLineVisibility 结束

每个控件的值必须与相应的图表属性同步。为了实现这一点,我们为图表属性设置了 Dependent 属性,然后再实现其 getset 方法。请注意,除了更新内部图形对象外,set 方法还必须更新控件对象的值。

最佳拟合线可见性对应的代码如下所示。为了确保该属性与复选框值的兼容性,我们将该属性转换为 matlab.lang.OnOffSwitchState 类型。此类型支持任何表示 truefalse 值的兼容语法,如 10,以及 "on""off"

properties (Dependent) 
    % 最佳拟合线的可见性。
    LineVisible(1, 1) matlab.lang.OnOffSwitchState 
end % properties (Dependent) 结束 

function value = get.LineVisible(obj) 

value = obj.BestFitLine.Visible; 

end % get.LineVisible 结束 

function set.LineVisible(obj, value) 

% 更新属性。 
obj.BestFitLine.Visible = value; 
% 更新复选框。 
obj.BestFitLineCheckBox.Value = value; 

end % set.LineVisible 结束

将图表与 App 设计工具集成

从 MATLAB R2021a 开始,使用 ComponentContainer 超类开发的图表可以与 App 设计工具集成(图 7)。利用 App 设计工具,您可以通过创建元数据将图表分享给最终用户。已安装的图表将随后出现在用户的 App 设计工具组件库中,用户可以在画布上交互式地使用该图表,就像使用其他组件一样。

图 7.与 App 设计工具集成的自定义图表。

图 7.与 App 设计工具集成的自定义图表。

小结

在这篇文章中,我们以 ScatterFit 图表为例,介绍了实现自定义图表的设计模式和最佳实践。许多常见的可视化任务都可以用适当的图表完成,尤其是那些需要动态图形的任务。设计和创建图表需要投入时间和精力进行前期开发,但图表可以大大简化许多可视化工作流。

2021年发布

了解更多

查看文章,了解相关功能