本系列文章由Tangram开发团队编写。Tangram是我们开发的一套面向Windows桌面的软件胶水技术。基于Tangram,开发者可以以一种全新的方式来构造桌面软件。Tangram本身是一个复杂的概念,我们希望通过本系列文章让读者真正的了解Tangram的思想。Tangram没有特定的语言限制,无论你是C++开发者,Java开发者还是.Net开发者。都可以从Tangram技术中获益。为了更方便的解释,下文中我们将从一个最简单的Win32应用程序开始逐步展示出Tangram的魅力。
第一节:全新的开始
桌面开发技术发展到今天,已经有许多简单快捷的方式让开发者轻松的构建桌面软件。如果你是一名C++开发者,你可以基于MFC开发桌面应用。如果你是一名.Net开发者,你可以基于WinForm技术开发基于控件的标准窗体程序。Java也有类似的技术,例如:SWT,SWING等。或者,你可以使用WPF基于XAML构建更富有想象力的程序界面。如果你是Web开发者,你也可以使用Electron开发基于HTML的Hybrid应用程序。这些技术各有各的优势和劣势。人们往往在权衡利弊之后,从中选择最适合自己的一种。但这些就是桌面开发的全部吗?我们说并不是。为了向大家展示这一点,让我们回到一切的开端。
Win32 API,这几乎是所有Windows开发技术的基础。在20年前,大多数VC++开发者都是使用这套API来构建桌面软件的。今天,我们将重新创建一个全新的Win32 工程,一步步的构建我们心目中的软件系统。
为了完成我们的演示,你需要一套最新的Visual Studio开发环境。在这里,我们是使用Visual Studio 2017 Enterprise版本,你可以使用Community或者Professional版本,这都没有问题。新版本的Visual Studio使用可选的方式让开发者选择自己需要的组件。在这里,你需要
- .NET desktop development
- Desktop development with C++
- Visual C++ MFC for x86 and x64
- C++/CLI support
- Office/SharePoint development
场景一
然后,让我们创建第一个全新的Win32工程,我们选择Visual C++ > Windows Desktop > Windows Desktop Application
Wizard默认为我们创建了一个空白窗口,这是一个Windows顶层窗口。
我们通过Visual Studio > Tools > Spy++ 解析这个窗口
可以查看这个窗口的基本信息,其中005604EE是它的窗口句柄,你那里可能有所不同。这里引出了Windows开发的核心概念,Window对象。在Microsoft的设计中,Windows中的所有可见和不可见元素几乎都是由Window对象直接或间接构成的。你可以使用Spy++中的望远镜在你的Windows桌面上扫描几次。你会发现那些形形色色的窗口,图标,按钮本质上都是Window对象。那么,我们就来创建第一个我们自己的Window对象。
每个Window都需要一个ClassName和Title
WCHAR szChildWindowTitle[] = TEXT("Win32Launcher Child Window"); // the child window title bar text
WCHAR szChildWindowClass[] = TEXT("Win32Launcher Child Window"); // the child window class name
我们需要使用ClassName向系统注册这个窗口,为了更好的区分,我们使用COLOR_HIGHLIGHT作为Window的背景色
//
// FUNCTION: RegisterChildWindowClass()
//
// PURPOSE: Registers the child window class.
//
ATOM RegisterChildWindowClass(HINSTANCE hInstance)
{
WNDCLASSEXW wcex;
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = ChildWindowProc;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = hInstance;
wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_WIN32LAUNCHER));
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = (HBRUSH)(COLOR_HIGHLIGHT);
wcex.lpszMenuName = MAKEINTRESOURCEW(IDC_WIN32LAUNCHER);
wcex.lpszClassName = szChildWindowClass;
wcex.hIconSm = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_SMALL));
return RegisterClassExW(&wcex);
}
每个Window都需要一个WndProc函数来处理发往该Window对象的消息,如果你没有额外的处理需求,可以直接调用默认处理函数DefWindowProc
// Message handler for child window
LRESULT CALLBACK ChildWindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
return DefWindowProc(hWnd, message, wParam, lParam);
}
我们计划让这个Window填满主窗口的客户区域。我们需要首先获取主窗口的客户区域尺寸
// Get the size of main window client area
RECT rc;
::GetClientRect(hWnd, &rc);
然后使用获取的尺寸信息创建我们的新Window
// Create a child window and populate the main window client area
hChildWnd = CreateWindowW(szChildWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS, 0, 0, rc.right - rc.left,
rc.bottom - rc.top, hWnd, NULL, hInstance, NULL);
// display the child window
ShowWindow(hChildWnd, nCmdShow);
UpdateWindow(hChildWnd);
为了确保新Window的尺寸能够随着主窗口尺寸的变化而相应的变化,我们需要额外处理主窗口的WM_WINDOWPOSCHANGED事件,并且在事件处理中相应的调整新Window的尺寸
case WM_WINDOWPOSCHANGED:
{
// Update the size of the child window when the size of main window
// is changed.
WINDOWPOS* lpwndpos = (WINDOWPOS*)lParam;
if (IsWindow(hChildWnd))
{
RECT rc;
::GetClientRect(hWnd, &rc);
SetWindowPos(hChildWnd, HWND_BOTTOM, 0, 0, rc.right - rc.left,
rc.bottom - rc.top, SWP_NOACTIVATE | SWP_NOREDRAW);
}
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
我们再次运行工程,将会看到一个灰色的窗口填满了原有窗口客户区域。
再次使用Spy++观察这个区域
我们看到,原有的主窗口下面添加了一个我们新建的子窗口。尝试调整主窗口尺寸,你会观察到子窗口尺寸跟随着变化。
场景二
在现实的应用场景中,一个应用程序窗口都是由许多不同的功能区域构成的。以Visual Studio为例,有编辑器区域,解决方案面板,输出面板,属性面板等。考虑多个窗口的情况,让我们再额外创建一个窗口,让两个子窗口左右对齐排列。
这里我们定义两个窗口句柄,为了美观,我们让两个窗口之间有4个像素的间隙。
HWND hLChildWnd; // the left child window handle
HWND hRChildWnd; // the right child window handle
LONG lGutterWidth = 4; // the gutter width
这里我们将左侧窗口的宽度设为(rc.right - rc.left - lGutterWidth) / 2
// Get the size of main window client area
RECT rc;
::GetClientRect(hWnd, &rc);
// Create a child window on the left
hLChildWnd = CreateWindowW(szChildWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS, 0, 0,
(rc.right - rc.left - lGutterWidth) / 2, rc.bottom - rc.top, hWnd, NULL,
hInstance, NULL);
同理,右侧窗口也做相应的调整
// Create a child window on the right
hRChildWnd = CreateWindowW(szChildWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth, 0,
(rc.right - rc.left - lGutterWidth) / 2, rc.bottom - rc.top, hWnd, NULL,
hInstance, NULL);
我们也需要在主窗口尺寸更新时调整子窗口的尺寸
case WM_WINDOWPOSCHANGED:
{
// Calculate the size of all child windows when the main window
// size is changed.
WINDOWPOS* lpwndpos = (WINDOWPOS*)lParam;
if (IsWindow(hLChildWnd) && IsWindow(hRChildWnd))
{
RECT rc;
::GetClientRect(hWnd, &rc);
SetWindowPos(hLChildWnd, HWND_BOTTOM, 0, 0,
(rc.right - rc.left - lGutterWidth) / 2,
rc.bottom - rc.top, SWP_NOACTIVATE | SWP_NOREDRAW);
SetWindowPos(hRChildWnd, HWND_BOTTOM,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth, 0,
(rc.right - rc.left - lGutterWidth) / 2,
rc.bottom - rc.top, SWP_NOACTIVATE | SWP_NOREDRAW);
}
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
再次运行程序,我们将看到
场景三
为了加深Window概念的理解,我们在场景二的基础上再加深一层。这次,我们创建一个1/2窗口和两个1/4窗口。
我们创建3个窗口句柄
HWND hLChildWnd; // the left child window handle
HWND hURChildWnd; // the upper right child window handle
HWND hLRChildWnd; // the lower right child window handle
LONG lGutterWidth = 4; // the gutter width
对于之前的右侧窗口,我们替换成上下两个窗口。首先创建右上的窗口
// Create a upper right child window
hURChildWnd = CreateWindowW(szChildWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth, 0,
(rc.right - rc.left - lGutterWidth) / 2,
(rc.bottom - rc.top - lGutterWidth) / 2, hWnd, NULL, hInstance, NULL);
接着我们创建右下角的窗口
// Create a lower right child window
hLRChildWnd = CreateWindowW(szChildWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth,
(rc.right - rc.left - lGutterWidth) / 2,
(rc.bottom - rc.top - lGutterWidth) / 2, hWnd, NULL, hInstance, NULL);
同理在WM_WINDOWPOSCHANGED中对窗口作出调整
case WM_WINDOWPOSCHANGED:
{
// Calculate the size of all child windows when the main window
// size is changed.
WINDOWPOS* lpwndpos = (WINDOWPOS*)lParam;
if (IsWindow(hLChildWnd) && IsWindow(hURChildWnd) && IsWindow(hLRChildWnd))
{
RECT rc;
::GetClientRect(hWnd, &rc);
SetWindowPos(hLChildWnd, HWND_BOTTOM, 0, 0,
(rc.right - rc.left - lGutterWidth) / 2,
rc.bottom - rc.top, SWP_NOACTIVATE | SWP_NOREDRAW);
SetWindowPos(hURChildWnd, HWND_BOTTOM,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth, 0,
(rc.right - rc.left - lGutterWidth) / 2,
(rc.bottom - rc.top - lGutterWidth) / 2,
SWP_NOACTIVATE | SWP_NOREDRAW);
SetWindowPos(hLRChildWnd, HWND_BOTTOM,
(rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth,
(rc.right - rc.left - lGutterWidth) / 2,
(rc.bottom - rc.top - lGutterWidth) / 2,
SWP_NOACTIVATE | SWP_NOREDRAW);
}
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
运行程序,我们看到
场景四
在之前的场景中,我们都假设窗口被平均的切分。现在我们试图让左侧的窗口拥有固定的宽度。 我们将它的宽度设置为200像素。
HWND hLChildWnd; // the left child window handle
HWND hURChildWnd; // the upper right child window handle
HWND hLRChildWnd; // the lower right child window handle
LONG lLChildWndWidth = 200; // the left child window width
LONG lGutterWidth = 4; // the gutter width
这次,为了美观,我们为窗口设置不同的背景颜色。为此,我们需要注册3个不同的窗口类
WCHAR szRedWindowClass[] = TEXT("Win32Launcher Red Window"); // the red child window class name
WCHAR szOrangeWindowClass[] = TEXT("Win32Launcher Orange Window"); // the orange child window class name
WCHAR szGreenWindowClass[] = TEXT("Win32Launcher Green Window"); // the green child window class name
我们修改原来的窗口注册函数,让它能够支持不同的背景颜色
//
// FUNCTION: RegisterChildWindowClass()
//
// PURPOSE: Registers the child window class with special background color.
//
ATOM RegisterChildWindowClass(HINSTANCE hInstance, LPCWSTR lpClassName, COLORREF color)
{
WNDCLASSEXW wcex;
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = ChildWindowProc;
wcex.cbClsExtra = 0;
wcex.cbWndExtra = 0;
wcex.hInstance = hInstance;
wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_WIN32LAUNCHER));
wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
wcex.hbrBackground = CreateSolidBrush(color);
wcex.lpszMenuName = MAKEINTRESOURCEW(IDC_WIN32LAUNCHER);
wcex.lpszClassName = lpClassName;
wcex.hIconSm = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_SMALL));
return RegisterClassExW(&wcex);
}
注册这些窗口
RegisterChildWindowClass(hInstance, szRedWindowClass, 0x004d5adc);
RegisterChildWindowClass(hInstance, szOrangeWindowClass, 0x0035befe);
RegisterChildWindowClass(hInstance, szGreenWindowClass, 0x009cb14b);
使用固定的宽度创建左侧的窗口
// Create a child window on the left
hLChildWnd = CreateWindowW(szRedWindowClass, szLWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS, 0, 0, lLChildWndWidth,
rc.bottom - rc.top, hWnd, NULL, hInstance, NULL);
创建右侧的两个窗口
// Create a upper right child window
hURChildWnd = CreateWindowW(szOrangeWindowClass, szURWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS,
lLChildWndWidth + lGutterWidth, 0,
(rc.right - rc.left) - lLChildWndWidth - lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2, hWnd, NULL, hInstance, NULL);
// display the upper right child window
ShowWindow(hURChildWnd, nCmdShow);
UpdateWindow(hURChildWnd);
// Create a lower right child window
hLRChildWnd = CreateWindowW(szGreenWindowClass, szLRWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS,
lLChildWndWidth + lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth,
(rc.right - rc.left) - lLChildWndWidth - lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2, hWnd, NULL, hInstance, NULL);
// display the lower right child window
ShowWindow(hLRChildWnd, nCmdShow);
UpdateWindow(hLRChildWnd);
固定宽度意味着主窗口尺寸改变时,仍然保持不变的宽度
case WM_WINDOWPOSCHANGED:
{
// Calculate the size of all child windows when the main window
// size is changed.
WINDOWPOS* lpwndpos = (WINDOWPOS*)lParam;
if (IsWindow(hLChildWnd) && IsWindow(hURChildWnd) && IsWindow(hLRChildWnd))
{
RECT rc;
::GetClientRect(hWnd, &rc);
SetWindowPos(hLChildWnd, HWND_BOTTOM, 0, 0, lLChildWndWidth,
rc.bottom - rc.top, SWP_NOACTIVATE | SWP_NOREDRAW);
SetWindowPos(hURChildWnd, HWND_BOTTOM,
lLChildWndWidth + lGutterWidth, 0,
(rc.right - rc.left) - lLChildWndWidth - lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2,
SWP_NOACTIVATE | SWP_NOREDRAW);
SetWindowPos(hLRChildWnd, HWND_BOTTOM,
lLChildWndWidth + lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth,
(rc.right - rc.left) - lLChildWndWidth - lGutterWidth,
(rc.bottom - rc.top - lGutterWidth) / 2,
SWP_NOACTIVATE | SWP_NOREDRAW);
}
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
为了更好的标识每个窗口,我们使用绘图API将每个窗口的标题文字绘制到窗口上。每当操作系统认为当前窗口需要重新绘制时,都会触发该WM_PAINT消息。
// Message handler for child window
LRESULT CALLBACK ChildWindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// Draw the WindowTitle text onto the window.
int length = GetWindowTextLengthW(hWnd) + 1;
LPWSTR lpWindowTitle = new WCHAR[length];
GetWindowTextW(hWnd, lpWindowTitle, length);
RECT rc;
GetClientRect(hWnd, &rc);
SetTextColor(hdc, 0x00ffffff);
SetBkMode(hdc, TRANSPARENT);
rc.left = 10;
rc.top = 10;
DrawText(hdc, lpWindowTitle, -1, &rc, DT_SINGLELINE | DT_NOCLIP);
delete lpWindowTitle;
EndPaint(hWnd, &ps);
}
break;
case WM_DESTROY:
PostQuitMessage(0);
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return 0;
}
让我们看一下添加了背景色之后的窗口
尝试改变主窗口的尺寸,你会观察到左侧窗口依旧保持相同的宽度。通过Spy++检查一下窗口结构
右键菜单选择属性,查看一下窗口的宽度
场景五
上文中最多只创建了3个子窗口,已经产生了尺寸问题。那么更加复杂的窗口结构该如何创建呢?这里我们引出一种参数化的创建思路。假设我们需要创建一种循环结构。将主窗口分为左右两个子窗口,将右侧的子窗口转换为上下两个子窗口。将上面的子窗口再次分成左右两个子窗口。依此类推。为此我们需要一种递归结构。
void RecursivelyCreateWindow(HINSTANCE hInstance, int nCmdShow, HWND hPWnd, int nPosIndex, int nLevel)
{
WCHAR* szWindowClass = NULL;
int x, y, nWidth, nHeight;
// Get the size of parent window client area
RECT rc;
::GetClientRect(hPWnd, &rc);
switch (nPosIndex)
{
case 1:
szWindowClass = szRedWindowClass;
x = rc.left;
y = rc.top;
nWidth = (rc.right - rc.left - lGutterWidth) / 2;
nHeight = rc.bottom - rc.top;
break;
case 2:
szWindowClass = szOrangeWindowClass;
x = (rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth;
y = rc.top;
nWidth = (rc.right - rc.left - lGutterWidth) / 2;
nHeight = rc.bottom - rc.top;
break;
case 3:
szWindowClass = szGrayWindowClass;
x = rc.left;
y = rc.top;
nWidth = rc.right - rc.left;
nHeight = (rc.bottom - rc.top - lGutterWidth) / 2;
break;
case 4:
szWindowClass = szGreenWindowClass;
x = rc.left;
y = (rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth;
nWidth = rc.right - rc.left;
nHeight = (rc.bottom - rc.top - lGutterWidth) / 2;
break;
}
HWND hWnd = CreateWindowW(szWindowClass, szChildWindowTitle,
WS_CHILDWINDOW | WS_VISIBLE | WS_CLIPSIBLINGS, x, y, nWidth,
nHeight, hPWnd, NULL, hInstance, NULL);
// display the window
ShowWindow(hWnd, nCmdShow);
UpdateWindow(hWnd);
if (nLevel < 6)
{
if (nPosIndex == 2)
{
RecursivelyCreateWindow(hInstance, nCmdShow, hWnd, 3, nLevel + 1);
RecursivelyCreateWindow(hInstance, nCmdShow, hWnd, 4, nLevel + 1);
}
else if (nPosIndex == 3)
{
RecursivelyCreateWindow(hInstance, nCmdShow, hWnd, 1, nLevel + 1);
RecursivelyCreateWindow(hInstance, nCmdShow, hWnd, 2, nLevel + 1);
}
}
mapWindows[hWnd] = nPosIndex;
}
其中hPWnd是待创建子窗口的父窗口。nPosIndex是位置的索引,1,2,3,4分别代表左,右,上,下。代码基于这个索引值决定如何在当前父窗口下进行切分。nLevel是递归的层数。与上文中的其它案例不同。为了方便定位,我们会创建一些仅仅用于定位的容器窗口。
为了后续的尺寸更新,我们需要保存所有创建的窗口句柄。这里我们建立了一个map结构。
std::map mapWindows; // the mapping between the window handle and the position index
在WM_WINDOWPOSCHANGED中,我们需要遍历主窗口下的所有子窗口,并更新它们的尺寸。
case WM_WINDOWPOSCHANGED:
{
// Calculate the size of all child windows when the parent window
// size is changed.
WINDOWPOS* lpwndpos = (WINDOWPOS*)lParam;
// Recursively update the child window position.
EnumChildWindows(hWnd, UpdateWindowPos, NULL);
return DefWindowProc(hWnd, message, wParam, lParam);
}
break;
EnumChildWindows需要一个Callback函数,这里同样存在递归逻辑。
// Recursively update the child window position.
BOOL CALLBACK UpdateWindowPos(_In_ HWND hWnd, _In_ LPARAM lParam)
{
std::map::iterator it = mapWindows.find(hWnd);
if (it != mapWindows.end())
{
int nPosIndex = it->second;
HWND hPWnd = ::GetParent(hWnd);
if (IsWindow(hPWnd))
{
RECT rc;
::GetClientRect(hPWnd, &rc);
WCHAR* szWindowClass = NULL;
int x, y, nWidth, nHeight;
switch (nPosIndex)
{
case 1:
szWindowClass = szRedWindowClass;
x = rc.left;
y = rc.top;
nWidth = (rc.right - rc.left - lGutterWidth) / 2;
nHeight = rc.bottom - rc.top;
break;
case 2:
szWindowClass = szOrangeWindowClass;
x = (rc.right - rc.left - lGutterWidth) / 2 + lGutterWidth;
y = rc.top;
nWidth = (rc.right - rc.left - lGutterWidth) / 2;
nHeight = rc.bottom - rc.top;
break;
case 3:
szWindowClass = szGrayWindowClass;
x = rc.left;
y = rc.top;
nWidth = rc.right - rc.left;
nHeight = (rc.bottom - rc.top - lGutterWidth) / 2;
break;
case 4:
szWindowClass = szGreenWindowClass;
x = rc.left;
y = (rc.bottom - rc.top - lGutterWidth) / 2 + lGutterWidth;
nWidth = rc.right - rc.left;
nHeight = (rc.bottom - rc.top - lGutterWidth) / 2;
break;
}
SetWindowPos(hWnd, HWND_BOTTOM, x, y, nWidth, nHeight,
SWP_NOACTIVATE | SWP_NOREDRAW);
}
EnumChildWindows(hWnd, UpdateWindowPos, lParam);
}
return TRUE;
}
运行程序,让我们看看最后实现的结果
是否有一种智力拼图的感觉?让我们再次使用Spy++观察一下窗口结构
这里我们就会发现,实际创建的窗口要比视觉上展示的窗口要多。那些额外创建的窗口就是上文所说的容器窗口,它们的职责主要是用于定位。当然,在现实开发中,并不存在如此有规律的嵌套结构。大多数情况,问题要比这复杂的多。这个例子仅仅向读者展示了窗口创建的不同可能方法和其中的复杂度。在相对混乱的对象中找出规律形成通用解决方案是一种基本的编程技巧。Tangram在此处给出了一种更加灵活高效的组织方法。但在介绍这种组织方法之前,我们希望读者了解另外一些知识点。