使用 MATLAB 面向对象编程创建专用图表
作者 Ken Deeley and David Sampson, MathWorks
开发高级 MATLAB® 可视化通常需要管理多个低级图形对象。这在包含动态更新图形的应用程序开发中尤为常见。这类应用程序的编程可能相当耗时。
图表对象可提供高级应用程序编程接口 (API),用于创建自定义可视化。图表不仅为最终用户提供了便捷的可视化 API,也免去了实现低级图形编程的需要。
MATLAB 包含一个面向对象的框架,用于通过以下容器超类开发自定义图:
matlab.graphics.chartcontainer.ChartContainer
(在 R2019b 中引入)matlab.ui.componentcontainer.ComponentContainer
(在 R2020b 中引入)
本文提供了使用此框架创建和实现自定义图表的分步指南,包括设计模式和最佳实践。下文将以包含最佳拟合线的散点图为例,对这些步骤加以说明。您将了解到如何:
- 编写标准图表模板
- 编写图表的
setup
和update
方法 - 封装数据和图形
- 为最终用户提供高级 API
- 加入交互式控件
另外,MATLAB 还针对特定应用创建了专用图表(图 2)。您可以从 File Exchange 下载这些图表以及本文使用的 MATLAB 代码。
创建二维散点图
假设我们要创建一个二维散点图,其中包含相应的最佳拟合线(图 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)
以上代码可以满足静态可视化的需求。不过,如果应用程序要求数据支持动态修改,那么我们会遇到几个难题:
- 如果我们将
XData
或YData
替换成一个与当前XData
长度相同的新数组,最佳拟合线并不会动态更新(图 4)。s.XData = s.XData + 4;
- 如果将散点图 (
Scatter
) 对象s
的任一数据属性(XData
或YData
)设置为长于或短于当前数组的新数组,该对象会发出警告,且不执行图形更新。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 函数结束
可以看到,此函数需要两个数据输入(x 和 y)。此外,您可以指定图形父项 f
(例如,一个图窗)作为第一个输入参数。
scatterfit(x, y)
指定两个数据输入。scatterfit(f, x, y)
指定图形的父项和数据。
在第一种情况下,该函数会呈现自动父级 (autoparenting) 行为,即会自动为该图表创建图窗。
使用函数创建图表有一些缺点:
- 创建图表后,无法修改数据。
- 要更改图表数据,您需要再次调用该函数,以重新创建该图表。
- 最终用户很难找到可配置的图表参数(例如标签,以及颜色、线型等装饰性的图形属性)。
如果用类来实现图表,您一样可以享受代码封装和可重用性带来的种种便利,而且无需重新创建图表即可进行修改。
选择图表超类:ChartContainer
还是 ComponentContainer
?
为了直接在原图表上进行修改,必须用句柄 (handle
) 类来实现图表。为了与 MATLAB 图形对象保持一致,除了标准圆点表示法之外,图表还应支持属性的 get
/set
语法。ChartContainer
和 ComponentContainer
均为句柄类,支持 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) |
根据图表的具体需求选择一个超类。如果图表不需要面向用户的交互式控件,例如按钮、下拉菜单和复选框,则从 ChartContainer
派生图表;否则,使用 ComponentContainer
。这是因为图表容器超类的顶层图形对象是一个分块布局,此对象可以包含坐标区,但不能包含用户控件。与组件容器关联的顶层图形对象则是一个类似面板的对象,同时支持坐标区和用户控件。
请注意,框架超类会自动管理图表生命周期,保证以下行为:
- 当图表图形被删除时(例如,通过关闭主图窗窗口),图表对象也会被删除。
- 当图表对象被删除时(例如,当它超出范围或当它的句柄被删除时),图表图形也会被删除。
对于所有图表输入参数,框架超类支持使用名称-值对组。这意味着在创建图表时不需要指定输入参数,所有输入都是可选的。
编写图表 setup
和 update
方法
我们现在需要实现两个特殊方法,这两个方法都是框架超类所需要的:
setup
:在创建图表时自动调用update
:在用户修改某些图表属性时自动调用
这些方法在我们的图表类中必须具有受保护的访问权限,因为它们在超类中具有此属性。
methods (Access = protected) function setup(obj) end % setup 结束 function update(obj) end % update 结束 end % methods (Access = protected) 结束
先考虑 setup
方法。该方法是类定义中的函数,我们将使用该类定义初始化图表。我们不妨先将 scatterfit
函数中的代码复制到 setup
方法中。然后,我们进行以下修改,以实现所需的图表行为:
父图形。不同于前文
scatterfit
函数中所述的方法,如果未指定Parent
输入,就不会为图表自动创建该输入。可以看到,此行为不同于plot
、scatter
等便利函数所表现出的自动父级行为。在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 |
我们稍后会继续介绍图表的 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 的机会。
- 图表数据可以轻松地同步(例如,我们要求关联
ScatterFit
的XData
和YData
属性)。
对于内部图形属性,我们推荐指定 Transient
和 NonCopyable
属性。这样一来,我们可以确保图表对象在保存到 MAT 文件时或在复制过程中行为正确。为了增加稳健性,并且为了在使用图表类时对图形属性启用 Tab 键自动填充功能,我们还实现了属性验证。
提供可视化 API
设计图表的主要目的之一是提供方便、直观的 API。我们使用与现有图形对象属性一致的名称,为 ScatterFit
图表设置了易于识别的属性(图 5)。
用户可以使用表 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
图表,我们支持对数据属性(XData
和 YData
)进行动态修改(包括长度更改)。当用户设置图表的(公共)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
方法就会自动调用。为了避免不必要的耗时计算,我们使用一个私有的内部逻辑属性 ComputationRequired
在 set
方法中记录是否有必要进行完全更新。
公共 API 属性如果在更改后不需要新的计算,则不需要 get
或 set
方法。这种情况下,我们只需在 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 结束
我们可以用同样的方式实现 YData
的 set
方法,只需在代码中将 X/YData
属性互换即可。
为了创建一个适合最终用户的、功能丰富的 API,我们实现了大量公共属性。请注意,Parent
、Position
、Units
和 Visible
等标准属性都是从超类继承而来,不需要在图表中额外实现。
添加图表注释方法
在该 API 中,我们提供了友好、易用的方法来注释图表。这些注释方法会重载相应的高级图形装饰函数,即与之同名。要使用这些方法,第一个输入参数应该是对图表的引用,然后是对装饰函数的输入。
xlabel(SF, "x-data", "FontSize", 12)
如果装饰函数支持,也可以在调用注释方法时提供一个输出,以返回对图形对象的引用,用于进一步自定义。例如,xlabel
函数返回一个文本对象。
xl = xlabel(SF, "x-data");
要支持名称-值对组和输出参数,比较便捷的方法是使用元胞数组 varargin
和 varargout
。语法 varargin{:}
生成一个以逗号分隔的输入参数列表。我们使用 nargout
确定调用方的输出个数。为了处理可变数量的输出参数(通常,这些方法的输出参数为 0 个或 1 个),我们在调用装饰函数时会使用语法 [varargout{1:nargout}]
。典型的注释方法结构如下:
function varargout = xlabel(obj, varargin) [varargout{1:nargout}] = xlabel(obj.Axes, varargin{:}); end % xlabel 结束
在图表中加入交互式控件
除了图表的 API 之外,我们还可以加入控件,为最终用户提供图表交互和修改选项(图 6)。
我们使用 App 构建组件在图表 setup
方法中初始化这些控件。每个控件都有一个回调函数,以私有方法实现。此方法具有三个输入参数:
- 图表对象。
- 对负责触发回调的源对象的引用。在本示例中,源对象是相应的用户控件。
- 事件数据。用户与控件交互时由 MATLAB 自动传递给回调函数的对象。事件数据对象包含有关事件的额外信息。
以控制最佳拟合线可见性的复选框的回调函数为例。该函数会根据复选框的值来切换底层线条对象的可见性。
function toggleLineVisibility(obj, s, ~) %TOGGLELINEVISIBILITY 切换最佳拟合线的可见性。 obj.BestFitLine.Visible = s.Value; end % toggleLineVisibility 结束
每个控件的值必须与相应的图表属性同步。为了实现这一点,我们为图表属性设置了 Dependent
属性,然后再实现其 get
和 set
方法。请注意,除了更新内部图形对象外,set
方法还必须更新控件对象的值。
最佳拟合线可见性对应的代码如下所示。为了确保该属性与复选框值的兼容性,我们将该属性转换为 matlab.lang.OnOffSwitchState
类型。此类型支持任何表示 true
和 false
值的兼容语法,如 1
和 0
,以及 "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 设计工具组件库中,用户可以在画布上交互式地使用该图表,就像使用其他组件一样。
小结
在这篇文章中,我们以 ScatterFit
图表为例,介绍了实现自定义图表的设计模式和最佳实践。许多常见的可视化任务都可以用适当的图表完成,尤其是那些需要动态图形的任务。设计和创建图表需要投入时间和精力进行前期开发,但图表可以大大简化许多可视化工作流。
2021 年发布