【问题标题】:What's the "right" way to organize GUI code?组织 GUI 代码的“正确”方式是什么?
【发布时间】:2013-11-19 06:23:33
【问题描述】:

我正在开发一个相当复杂的 GUI 程序,该程序将与 MATLAB Compiler 一起部署。 (有充分的理由使用 MATLAB 来构建这个 GUI,这不是这个问题的重点。我意识到 GUI 构建不是这种语言的强项。)

有很多方法可以在 GUI 中的函数之间共享数据,甚至在应用程序中的 GUI 之间传递数据:

  • setappdata/getappdata/_____appdata - 将任意数据关联到句柄
  • guidata - 通常与 GUIDE 一起使用; “存储 [s] 或检索 [s] GUI 数据”到句柄结构
  • set/get 操作应用于句柄对象的UserData 属性
  • 在主函数中使用嵌套函数;基本上模拟“全局”范围变量。
  • 在子函数之间来回传递数据

我的代码结构不是最漂亮的。现在我将引擎与前端隔离(很好!),但 GUI 代码非常像意大利面条。这是一个“活动”的骨架,借用 Android 的说法:

function myGui

    fig = figure(...); 

    % h is a struct that contains handles to all the ui objects to be instantiated. My convention is to have the first field be the uicontrol type I'm instantiating. See draw_gui nested function

    h = struct([]);


    draw_gui;
    set_callbacks; % Basically a bunch of set(h.(...), 'Callback', @(src, event) callback) calls would occur here

    %% DRAW FUNCTIONS

    function draw_gui
        h.Panel.Panel1 = uipanel(...
            'Parent', fig, ...
            ...);

        h.Panel.Panel2 = uipanel(...
            'Parent', fig, ...
            ...);


        draw_panel1;
        draw_panel2;

        function draw_panel1
             h.Edit.Panel1.thing1 = uicontrol('Parent', h.Panel.Panel1, ...);
        end
        function draw_panel2
             h.Edit.Panel2.thing1 = uicontrol('Parent', h.Panel.Panel2, ...);
        end


    end

    %% CALLBACK FUNCTIONS
    % Setting/getting application data is done by set/getappdata(fig, 'Foo').
end

我以前编写过没有嵌套的代码,所以我最终在各处来回传递h(因为需要重新绘制、更新等内容)和setappdata(fig) 来存储实际数据。无论如何,我一直将一个“活动”保存在一个文件中,我相信这将成为未来的维护噩梦。回调与应用程序数据和图形句柄对象交互,我认为这是必要的,但这会阻止代码库的两个“半”完全分离。

所以我在这里寻找一些组织/GUI 设计方面的帮助。即:

  • 是否有我应该用来组织的目录结构? (回调与绘图函数?)
  • 与 GUI 数据交互并将其与应用程序数据隔离的“正确方法”是什么? (当我提到 GUI 数据时,我的意思是 set/getting 句柄对象的属性)。
  • 如何避免将所有这些绘图功能放入一个包含数千行的巨大文件中,并且仍然有效地来回传递应用程序和 GUI 数据?这可能吗?
  • 持续使用set/getappdata 是否有任何性能损失?
  • 我的后端代码(3 个对象类和一堆辅助函数)是否应该采用任何结构以使其从 GUI 角度更易于维护?

我不是专业的软件工程师,我只是知道足够危险,所以我确信对于经验丰富的 GUI 开发人员(任何语言)来说,这些都是相当基本的问题。我几乎觉得 MATLAB 中缺乏 GUI 设计标准(存在吗?)严重干扰了我完成这个项目的能力。这是一个 MATLAB 项目,比我做过的任何一个项目都要庞大,而且我以前从来不需要过多考虑具有多个图形窗口等的复杂 UI。

【问题讨论】:

标签: matlab user-interface matlab-guide matlab-deployment


【解决方案1】:

正如@SamRoberts 解释的那样,Model–view–controller (MVC) 模式非常适合作为设计 GUI 的架构。我同意没有很多 MATLAB 示例可以展示这种设计......

以下是我编写的一个完整而简单的示例,用于在 MATLAB 中演示基于 MVC 的 GUI。

  • 模型表示某个信号y(t) = sin(..t..)的一维函数。它是一个句柄类对象,这样我们就可以在不创建不必要的副本的情况下传递数据。它公开了可观察的属性,允许其他组件侦听更改通知。

  • 视图 将模型显示为线图形对象。该视图还包含一个滑块来控制信号属性之一,并监听模型更改通知。我还包括了一个特定于视图(不是模型)的交互式属性,可以使用右键单击上下文菜单来控制线条颜色。

  • 控制器负责初始化所有内容并响应视图中的事件并相应地正确更新模型。

请注意,视图和控制器是作为常规函数编写的,但如果您更喜欢完全面向对象的代码,您可以编写类。

与设计 GUI 的通常方式相比,这有点额外的工作,但这种架构的优点之一是数据与表示层的分离。这使得代码更清晰、更易读,尤其是在使用复杂的 GUI 时,代码维护变得更加困难。

这种设计非常灵活,因为它允许您构建相同数据的多个视图。您甚至可以拥有多个同时视图,只需在控制器中实例化更多视图实例,然后查看一个视图中的更改如何传播到另一个视图!如果您的模型可以以不同的方式呈现,这将特别有趣。

此外,如果您愿意,可以使用 GUIDE 编辑器来构建界面,而不是以编程方式添加控件。在这样的设计中,我们只会使用 GUIDE 通过拖放来构建 GUI 组件,但我们不会编写任何回调函数。所以我们只对生成的.fig 文件感兴趣,而忽略随附的.m 文件。我们将在视图函数/类中设置回调。这基本上是我在 View_FrequencyDomain 视图组件中所做的,它加载了使用 GUIDE 构建的现有 FIG 文件。


模型.m

classdef Model < handle
    %MODEL  represents a signal composed of two components + white noise
    % with sampling frequency FS defined over t=[0,1] as:
    %   y(t) = a * sin(2pi * f*t) + sin(2pi * 2*f*t) + white_noise

    % observable properties, listeners are notified on change
    properties (SetObservable = true)
        f       % frequency components in Hz
        a       % amplitude
    end

    % read-only properties
    properties (SetAccess = private)
        fs      % sampling frequency (Hz)
        t       % time vector (seconds)
        noise   % noise component
    end

    % computable dependent property
    properties (Dependent = true, SetAccess = private)
        data    % signal values
    end

    methods
        function obj = Model(fs, f, a)
            % constructor
            if nargin < 3, a = 1.2; end
            if nargin < 2, f = 5; end
            if nargin < 1, fs = 100; end
            obj.fs = fs;
            obj.f = f;
            obj.a = a;

            % 1 time unit with 'fs' samples
            obj.t = 0 : 1/obj.fs : 1-(1/obj.fs);
            obj.noise = 0.2 * obj.a * rand(size(obj.t));
        end

        function y = get.data(obj)
            % signal data
            y = obj.a * sin(2*pi * obj.f*obj.t) + ...
                sin(2*pi * 2*obj.f*obj.t) + obj.noise;
        end
    end

    % business logic
    methods
        function [mx,freq] = computePowerSpectrum(obj)
            num = numel(obj.t);
            nfft = 2^(nextpow2(num));

            % frequencies vector (symmetric one-sided)
            numUniquePts = ceil((nfft+1)/2);
            freq = (0:numUniquePts-1)*obj.fs/nfft;

            % compute FFT
            fftx = fft(obj.data, nfft);

            % calculate magnitude
            mx = abs(fftx(1:numUniquePts)).^2 / num;
            if rem(nfft, 2)
                mx(2:end) = mx(2:end)*2;
            else
                mx(2:end -1) = mx(2:end -1)*2;
            end
        end
    end
end

View_TimeDomain.m

function handles = View_TimeDomain(m)
    %VIEW  a GUI representation of the signal model

    % build the GUI
    handles = initGUI();
    onChangedF(handles, m);    % populate with initial values

    % observe on model changes and update view accordingly
    % (tie listener to model object lifecycle)
    addlistener(m, 'f', 'PostSet', ...
        @(o,e) onChangedF(handles,e.AffectedObject));
end

function handles = initGUI()
    % initialize GUI controls
    hFig = figure('Menubar','none');
    hAx = axes('Parent',hFig, 'XLim',[0 1], 'YLim',[-2.5 2.5]);
    hSlid = uicontrol('Parent',hFig, 'Style','slider', ...
        'Min',1, 'Max',10, 'Value',5, 'Position',[20 20 200 20]);
    hLine = line('XData',NaN, 'YData',NaN, 'Parent',hAx, ...
        'Color','r', 'LineWidth',2);

    % define a color property specific to the view
    hMenu = uicontextmenu;
    hMenuItem = zeros(3,1);
    hMenuItem(1) = uimenu(hMenu, 'Label','r', 'Checked','on');
    hMenuItem(2) = uimenu(hMenu, 'Label','g');
    hMenuItem(3) = uimenu(hMenu, 'Label','b');
    set(hLine, 'uicontextmenu',hMenu);

    % customize
    xlabel(hAx, 'Time (sec)')
    ylabel(hAx, 'Amplitude')
    title(hAx, 'Signal in time-domain')

    % return a structure of GUI handles
    handles = struct('fig',hFig, 'ax',hAx, 'line',hLine, ...
        'slider',hSlid, 'menu',hMenuItem);
end

function onChangedF(handles,model)
    % respond to model changes by updating view
    if ~ishghandle(handles.fig), return, end
    set(handles.line, 'XData',model.t, 'YData',model.data)
    set(handles.slider, 'Value',model.f);
end

View_FrequencyDomain.m

function handles = View_FrequencyDomain(m)    
    handles = initGUI();
    onChangedF(handles, m);

    hl = event.proplistener(m, findprop(m,'f'), 'PostSet', ...
        @(o,e) onChangedF(handles,e.AffectedObject));
    setappdata(handles.fig, 'proplistener',hl);
end

function handles = initGUI()
    % load FIG file (its really a MAT-file)
    hFig = hgload('ViewGUIDE.fig');
    %S = load('ViewGUIDE.fig', '-mat');

    % extract handles to GUI components
    hAx = findobj(hFig, 'tag','axes1');
    hSlid = findobj(hFig, 'tag','slider1');
    hTxt = findobj(hFig, 'tag','fLabel');
    hMenu = findobj(hFig, 'tag','cmenu1');
    hMenuItem = findobj(hFig, 'type','uimenu');

    % initialize line and hook up context menu
    hLine = line('XData',NaN, 'YData',NaN, 'Parent',hAx, ...
        'Color','r', 'LineWidth',2);
    set(hLine, 'uicontextmenu',hMenu);

    % customize
    xlabel(hAx, 'Frequency (Hz)')
    ylabel(hAx, 'Power')
    title(hAx, 'Power spectrum in frequency-domain')

    % return a structure of GUI handles
    handles = struct('fig',hFig, 'ax',hAx, 'line',hLine, ...
        'slider',hSlid, 'menu',hMenuItem, 'txt',hTxt);
end

function onChangedF(handles,model)
    [mx,freq] = model.computePowerSpectrum();
    set(handles.line, 'XData',freq, 'YData',mx)
    set(handles.slider, 'Value',model.f)
    set(handles.txt, 'String',sprintf('%.1f Hz',model.f))
end

控制器.m

function [m,v1,v2] = Controller
    %CONTROLLER  main program

    % controller knows about model and view
    m = Model(100);           % model is independent
    v1 = View_TimeDomain(m);  % view has a reference of model

    % we can have multiple simultaneous views of the same data
    v2 = View_FrequencyDomain(m);

    % hook up and respond to views events
    set(v1.slider, 'Callback',{@onSlide,m})
    set(v2.slider, 'Callback',{@onSlide,m})
    set(v1.menu, 'Callback',{@onChangeColor,v1})
    set(v2.menu, 'Callback',{@onChangeColor,v2})

    % simulate some change
    pause(3)
    m.f = 10;
end

function onSlide(o,~,model)
    % update model (which in turn trigger event that updates view)
    model.f = get(o,'Value');
end

function onChangeColor(o,~,handles)
    % update view
    clr = get(o,'Label');
    set(handles.line, 'Color',clr)
    set(handles.menu, 'Checked','off')
    set(o, 'Checked','on')
end

在上面的控制器中,我实例化了两个独立但同步的视图,它们都表示和响应同一底层模型中的更改。一个视图显示信号的时域,另一个显示使用 FFT 的频域表示。

【讨论】:

  • 感谢您通过hgload 将这些点与 GUIDE 生成的 .fig 连接起来,因为即使您决定不使用它生成的回调,GUIDE 也是一个有用的工具。
  • 为了扩展它 - 我可能需要为我为我的应用程序构建的每个 GUI 使用不同的模型/控制器/视图?也许我可以保留一个模型并拥有多个控制器/视图。这如何扩展到具有大量窗口的应用程序?
  • @DangKhoa:视图和控制器都松散地耦合到模型的接口,因此我们通常有一个模型,以及任意数量的视图/控制器对。我更新了我的示例以显示相同数据的两个不同视图,同时同步。您可能可以稍微重构一些东西以减少这种耦合并在视图之间制作可重用的控制器,但您仍然必须遵循 MVC 组件如何交互的准则(模型是独立的,视图依赖于模型,控制器依赖于两者): programmers.stackexchange.com/q/148377/1379
  • @Amro,尽管提供如此广泛的示例对您有很大帮助(所以 +1),但我认为您的回答在某些方面并未展示最佳实践或建议。特别是,我建议 i)不要使用 GUIDE - 有很多原因,但特别是因为 .fig 文件不是版本可控的(好吧,无论如何都是可区分的),因此以这种方式开发的应用程序将更难维护 ii) ...
  • @ Amro ... ii) 这可能是我的观点/偏好而不是事实,但是具有多个窗口的 GUI 更难以组织,除非有非常充分的理由,否则,只是可用性差。特别是如果您使用的是 GUI 布局工具箱,对于大多数应用程序来说,单窗口设计更易于维护和使用。
【解决方案2】:

UserData property 是 MATLAB 对象的一个​​有用但遗留的属性。 “AppData”方法套件(即setappdatagetappdatarmappdataisappdata 等)为相对更笨拙的get/set(hFig,'UserData',dataStruct) 方法(IMO)提供了一个很好的替代方案。事实上,为了管理 GUI 数据,GUIDE 使用了guidata 函数,它只是setappdata/getappdata 函数的包装器

想到的 AppData 方法相对于 'UserData' 属性的几个优点:

  • 为多个异构属性提供更自然的界面。

    UserData 仅限于单个变量,需要您设计另一层数据组织(即结构)。假设您要存储一个字符串str = 'foo' 和一个数字数组v=[1 2]。使用UserData,您需要采用诸如s = struct('str','foo','v',[1 2]);set/get 之类的结构方案,只要您需要任何一个属性(例如s.str = 'bar'; set(h,'UserData',s);)。使用setappdata,流程更直接(高效):setappdata(h,'str','bar');

  • 底层存储空间的受保护接口。

    虽然'UserData' 只是一个常规句柄图形属性,但包含应用程序数据的属性是不可见的,尽管可以通过名称访问它('ApplicationData',但不要这样做!)。您必须使用 setappdata 来更改任何现有的 AppData 属性,这可以防止您在尝试更新单个字段时意外破坏 'UserData' 的全部内容。此外,在设置或获取 AppData 属性之前,您可以使用 isappdata 验证命名属性的存在,这有助于异常处理(例如,在设置输入值之前运行进程回调)和管理 GUI 的状态或它管理的任务(例如,通过某些属性的存在来推断进程的状态并适当地更新 GUI)。

'UserData''ApplicationData' 属性之间的一个重要区别是'UserData' 默认为[](一个空数组),而'ApplicationData' 本身是一个结构。这种差异,再加上setappdatagetappdata 没有M 文件实现(它们是内置的)这一事实,表明使用setappdata 设置命名属性确实不是 需要重写数据结构的全部内容。 (想象一个 MEX 函数对结构字段进行就地修改 - MATLAB 能够通过将结构维护为 'ApplicationData' 句柄图形属性的基础数据表示来实现操作。)


guidata 函数是 AppData 函数的包装器,但仅限于单个变量,例如 'UserData'。这意味着您必须覆盖包含所有数据字段的整个数据结构才能更新单个字段。一个明显的优势是您可以从回调中访问数据而无需实际的图形句柄,但就我而言,如果您对以下语句感到满意,这并不是一个很大的优势:

hFig = ancestor(hObj,'Figure')

另外,as stated by MathWorks,还有效率问题:

在“句柄”结构中保存大量数据有时会导致速度相当慢,尤其是在 GUI 的各种子功能中经常调用 GUIDATA 时。因此,建议仅使用“句柄”结构来存储图形对象的句柄。对于其他类型的数据,应使用 SETAPPDATA 和 GETAPPDATA 将其存储为应用程序数据。

此语句支持我的断言,即在使用 setappdata 修改单个命名属性时不会重写整个 'ApplicationData'。 (另一方面,guidatahandles 结构填充到称为'UsedByGUIData_m''ApplicationData' 字段中,因此很清楚为什么guidata 需要在更改一个属性时重写所有GUI 数据) .


嵌套函数只需要很少的工作(不需要辅助结构或函数),但它们显然将数据范围限制在 GUI 中,使得其他 GUI 或函数在不将值返回到基础工作区或一个通用的调用函数。显然,这会阻止您将子功能拆分为单独的文件,只要您传递图形句柄,您就可以使用 'UserData' 或 AppData 轻松完成此操作。


综上所述,如果选择使用句柄属性来存储和传递数据,可以同时使用guidata来管理图形句柄(不是大数据) setappdata/@ 987654374@ 获取实际程序数据。 它们不会相互覆盖,因为guidataApplicationData 中为handles 结构创建了一个特殊的'UsedByGUIData_m' 字段(除非您自己错误地使用了该属性!)。重申一下,不要直接访问ApplicationData

但是,如果您对 OOP 感到满意,那么通过类实现 GUI 功能可能会更简洁,将句柄和其他数据存储在成员变量中而不是句柄属性中,​​并在方法中调用回调可以存在于separate files under the class or package folder。有一个nice example on MATLAB Central File Exchange。此提交演示了如何使用类简化传递数据,因为不再需要不断获取和更新guidata(成员变量始终是最新的)。然而,还有一个额外的任务是管理退出时的清理,提交通过设置图形的closerequestfcn 来完成,然后调用类的delete 函数。提交的内容与 GUIDE 示例非常相似。

这些是我看到的亮点,但更多细节和不同的想法是discussed by MathWorks。另请参阅 this official answerUserDataguidatasetappdata/getappdata

【讨论】:

  • 最后,getappdata/setappdata 只不过是获取/设置存储在隐藏图形属性ApplicationData 中而不是UserData 中的结构的包装器。因此,在性能方面,它们应该非常相似。
  • @sebastian 我不一定会得出这个结论。一方面,MathWorks 声明 getappdata/setappdataguidata 具有更好的性能意味着并非 ApplicationData 中的所有结构都需要重写。此外,如果您执行get(h,'ApplicationData'),您会看到它本身就是一个结构,而get(h,'UserData')[],要求您在属性中存储一个结构。没有 getappdata/setappdata 的 M 文件实现这一事实表明了对 ApplicationData 结构属性的更有效的访问方法。
  • 所以您也将 GUI 数据(例如,“句柄结构”)存储为 ApplicationData?我想这并没有太大的区别。只想定义一个约定并坚持下去
  • @DangKhoa 您可以使用guidata 来存储图形句柄(不是用户数据)和set/getappdata 来存储实际数据。它们不会相互覆盖,因为guidata 使用了ApplicationData 的特殊'UsedByGUIData_m' 属性(除非您自己错误地使用了该属性!)。来自here:“使用 getappdata、setappdata 和 rmappdata 函数不会影响 GUI 数据。”重申一下,不要直接访问'ApplicationData'
【解决方案3】:

我不同意 MATLAB 不适合实现(甚至是复杂的)GUI——它完全没问题。

然而,事实是这样的:

  1. MATLAB 文档中没有关于如何实现或组织复杂的 GUI 应用程序的示例
  2. 简单 GUI 的所有文档示例都使用了无法很好地扩展到复杂 GUI 的模式
  3. 特别是,GUIDE(用于自动生成 GUI 代码的内置工具)会生成糟糕的代码,如果您自己实现某些东西,这是一个可怕的例子。

由于这些原因,大多数人只接触到非常简单或非常糟糕的 MATLAB GUI,他们最终认为 MATLAB 不适合制作 GUI。

根据我的经验,在 MATLAB 中实现复杂 GUI 的最佳方式与在另一种语言中实现的方式相同 - 遵循一种常用的模式,例如 MVC(模型-视图-控制器)。

但是,这是一种面向对象的模式,因此首先您必须熟悉 MATLAB 中的面向对象编程,尤其是事件的使用。为您的应用程序使用面向对象的组织应该意味着您提到的所有讨厌的技术(setappdataguidataUserData、嵌套函数范围以及来回传递多个数据副本)都是不必要的,因为所有相关的东西可以作为类属性使用。

我所知道的 MathWorks 发布的最佳示例是来自 MATLAB Digest 的 this article。即使那个例子也很简单,但它让你知道如何开始,如果你研究 MVC 模式,应该会清楚如何扩展它。

此外,我通常大量使用包文件夹来组织 MATLAB 中的大型代码库,以确保没有名称冲突。

最后一个提示 - 使用来自 MATLAB Central 的 GUI Layout Toolbox。它使 GUI 开发的许多方面变得更加容易,特别是实现自动调整大小行为,并为您提供了几个额外的 UI 元素供您使用。

希望有帮助!


编辑:在 MATLAB R2016a 中,MathWorks 引入了 AppDesigner,这是一个新的 GUI 构建框架,旨在逐步取代 GUIDE。

AppDesigner 在几个方面代表了与 MATLAB 中以前的 GUI 构建方法的重大突破(最深刻的是,生成的底层图形窗口基于 HTML 画布和 JavaScript,而不是 Java)。这是在 R2014b 中引入 Handle Graphics 2 所开创的道路上的又一步,无疑将在未来的版本中进一步发展。

但 AppDesigner 对所提问题的影响之一是它生成的代码比 GUIDE 生成的代码好多 - 它非常干净、面向对象,并且适合构成 MVC 模式的基础。

【讨论】:

  • 这很有帮助!我对 MATLAB OOP 和事件很满意,而且我还在使用 GUI 布局工具箱(主要用于 TabPanel)。我将阅读 MVC 设计模式。
  • +1 MVC 是将数据层与表示层分离的众所周知的设计。我发布了一个可能的 MVC 实现示例。
【解决方案4】:

我对 GUIDE 生成函数的方式感到非常不舒服。 (想想你想从另一个 gui 调用一个 gui 的情况)

我强烈建议您使用句柄类编写面向对象的代码。这样你就可以做一些花哨的事情(例如this)而不会迷路。对于组织代码,您有 +@ 目录。

【讨论】:

    【解决方案5】:

    我不认为结构化 GUI 代码与非 GUI 代码有根本的不同。

    将属于一起的东西放在某个位置。 就像可能进入 utilhelpers 目录的辅助函数一样。视内容而定,可以做成一个包。


    我个人不喜欢某些 MATLAB 人所拥有的“一个功能一个 m 文件”的理念。 把这样的功能:

    function pushbutton17_callback(hObject,evt, handles)
        some_text = someOtherFunction();
        set(handles.text45, 'String', some_text);
    end
    

    到一个单独的文件中根本没有意义,当没有任何场景时,您可以从其他地方然后从您自己的 GUI 调用它。


    但是,您可以以模块化方式构建 GUI 本身,例如通过通过简单地传递父容器来创建某些组件:

     handles.panel17 = uipanel(...);
     createTable(handles.panel17); % creates a table in the specified panel
    

    这也简化了某些子组件的测试 - 您可以简单地在空图上调用 createTable 并测试表格的某些功能,而无需加载完整的应用程序。


    当我的应用程序变得越来越大时,我开始使用另外两个项目:

    在回调上使用监听器,它们可以显着简化 GUI 编程。

    如果您有非常大的数据(例如来自数据库等),则可能值得实现一个保存这些数据的句柄类。 将此句柄存储在 guidata/appdata 中的某个位置会显着提高 get/setappdata 性能。

    编辑:

    回调的监听器:

    pushbutton 是个坏例子。按下按钮通常只会触发某些动作,这里回调很好恕我直言。 在我的情况下的主要优势,例如是否以编程方式更改文本/弹出列表不会触发回调,而其StringValue 属性上的侦听器会被触发。

    另一个例子:

    如果应用程序中的多个组件依赖于某个中心属性(例如某个输入数据源),那么使用侦听器可以非常方便地确保在属性更改时通知所有组件。 每个对此属性“感兴趣”的新组件都可以简单地添加它自己的侦听器,因此无需集中修改回调。 这允许对 GUI 组件进行更加模块化的设计,并使此类组件的添加/删除更加容易。

    【讨论】:

    • 你能举一个使用监听器而不是回调的例子吗?你如何使用回调来解决,比如,一个被按下的pushbutton 对象?
    • 我在答案中添加了一个示例。
    猜你喜欢
    • 2020-11-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-10-12
    • 2023-04-09
    • 1970-01-01
    • 2022-06-16
    • 2018-12-03
    相关资源
    最近更新 更多