起因

STemWin 自带的控件(BUTTON、TEXT 之类)不能满足界面需求时,就得写自定义控件。官方的做法是参照 WIDGET_ 前缀的 demo:皮肤、通知、内存管理一套全带上,几百行起步,第一次照抄很容易迷路——分不清哪些是骨架必需、哪些是可选增强。于是有了这篇:把自定义控件精简到最小可用骨架。

需求

  • 在父窗口里创建一个自定义控件,本例的效果是黄底、文本居中显示
  • 控件自己管理自己的数据(一个字符串),多个实例互不干扰
  • 代码尽量短,能一眼看清“自定义控件”到底由什么构成

技术实现原理

控件的本质:一个绑了回调的子窗口

emWin 的窗口管理器“万物皆窗口”:BUTTON 也好、FRAMEWIN 也好,本质都是一个带回调函数的子窗口,控件与普通窗口的差别只在回调里对消息的处理。所以自定义控件的最小骨架就两件事:WM_CreateWindowAsChild() 创建子窗口并指定回调;回调里响应 WM_PAINT,把自己画出来。看懂这一层再回头看官方 demo,那一大堆代码都是在给骨架加肉——皮肤是画得更好看,WM_NOTIFICATION 是把“我被点了”告诉父窗口,内存管理是处理创建/删除的生命周期。

实例数据放哪:userdata 机制

控件没有 C++ class 那种“成员变量”语法,回调函数又是同一个,每个实例自己的数据挂哪儿?emWin 的答案是 userdata:WM_CreateWindowAsChild() 最后一个参数让窗口多带 sizeof(MYWIDGET_Obj) 字节的私有存储,WM_SetUserData() 写、WM_GetUserData() 读,每个窗口实例各一份。为什么不用 static——static 是全部实例共享一份,界面上放两个 MYWIDGET 就会互相覆盖。

什么时候画:失效-重绘模型

emWin 是“标记失效、集中重绘”的模型:窗口被遮挡后露出、被移动、或代码调用 WM_InvalidateWindow() 时,WM 才给回调发 WM_PAINT。回调在 WM_PAINT 里用 GUI_* API 作画,画之前 emWin 已把裁剪区设成该窗口的矩形,不会画到别的窗口地盘。这也解释了最常见的现象“改了数据界面不动”——改 userdata 只是改了数据,要再触发一次失效,重绘才会把新数据画出来。

代码实现

笔者把STemWin官方给的自定义控件demo进行了精简,得出了这个最简单的自定义控件实现方法,所有代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <string.h>
#include "GUI.h"
#include "WM.h"
typedef struct {
  char str[50];
} MYWIDGET_Obj;
static void _cbMyWidget(WM_MESSAGE * pMsg) {
  GUI_RECT WinRect;
  MYWIDGET_Obj MyWidget;
  switch (pMsg->MsgId) {
  case WM_PAINT:
    WM_GetWindowRectEx(pMsg->hWin, &WinRect);
    GUI_MoveRect(&WinRect, -WinRect.x0, -WinRect.y0);
    GUI_SetColor(GUI_YELLOW);
    GUI_Clear();
    WM_GetUserData(pMsg->hWin, &MyWidget, sizeof(MYWIDGET_Obj));
    if(MyWidget.str)
    {
        GUI_DispStringInRect(MyWidget.str, &WinRect, GUI_TA_HCENTER | GUI_TA_VCENTER);
    }
    break;
  }
}
WM_HWIN MYWIDGET_Create(int x0, int y0, int xSize, int ySize, WM_HWIN hWinParent, U32 Style, const char * pText) {
  WM_HWIN   hWin;
  MYWIDGET_Obj      MyWidget;
  if (pText) {
    strcpy(MyWidget.str,  pText);
  }
  hWin = WM_CreateWindowAsChild(x0, y0, xSize, ySize, hWinParent, Style, _cbMyWidget, sizeof(MYWIDGET_Obj));
  WM_SetUserData(hWin, &MyWidget, sizeof(MYWIDGET_Obj));
  return hWin;
}

代码说明

代码的核心就是WM_CreateWindowAsChild这个函数,你把你的自定义控件的位置、大小、父窗口、回调函数、申请内存大小等传给它,它就在父窗口中创建你的自定义控件了。
MYWIDGET_Obj这个结构体就是你的自定义控件的数据结构,包含它需要用到的全局变量,这个例子我只写了一个字符串在里面。

在WM_CreateWindowAsChild创建控件成功后,你就可以使用WM_SetUserData函数往你的自定义控件里面写东西了,这个例子中,写的就是str这个字符串,最后你的自定义控件的回调函数_cbMyWidget会被调用,在它的重绘命令中你就可以根据需要,使用STemWin的API画出你的自定义控件了。

对应到上面的原理:回调只处理 WM_PAINT 一个消息,用 WM_GetWindowRectEx + GUI_MoveRect 把屏幕坐标换算成窗口局部坐标后作画;MYWIDGET_Obj 通过 userdata 挂在窗口上,创建时写入、重绘时读出——一个“数据 + 重绘”的最小闭环。

踩坑记录与注意事项

  • 改了数据不刷新:WM_SetUserData 之后界面纹丝不动,根因是重绘只在失效时发生,改完数据要跟一句 WM_InvalidateWindow(hWin);
  • 未知消息别静默丢弃:本例 switch 里没有 default 分支,纯展示能跑;一旦控件要参与焦点/键盘/触摸,没交 WM_DefaultProc 的消息会导致行为异常,正式代码补上 default: WM_DefaultProc(pMsg); break;
  • WM_GetWindowRectEx 返回屏幕坐标:不做 GUI_MoveRect 归一化,GUI_Clear、GUI_DispStringInRect 会画到错误位置,父窗口不在 (0,0) 时尤其明显;
  • strcpy 定长溢出:str[50] 写死,pText 超过 49 字节就是内存越界,正式代码换 strncpy 并保证结尾 \0;
  • 绘制都要放进 WM_PAINT:在回调其他分支或外部直接调 GUI_* 画窗口区域,画完就会被下一次重绘覆盖,等于白画;
  • userdata 大小在创建时定死:改了 MYWIDGET_Obj 结构体后 sizeof 会自动跟随,但已创建的窗口不会自动扩容,结构体变更要重建窗口。

运行效果如下: