由于微软激进的升级策略和新预装PC的销售,Win 10 的用户量变得越来越不可忽视。随着 Win 10 逐渐自上而下渗透到小白用户,用户习惯被打乱给我们团队带来了一些机会。首当其冲的开始菜单,被微软改的面目全非,于是我们就从开始菜单下手,尝试优化用户使用 Win 10 系统破碎的体验……

一、界面设计

做法

1、我们采用了 ATL 设计界面,图中每个控件都是一个子窗口,通过继承CWindowImpl获得窗口函数、样式等,通过DECLARE_WND_CLASS、BEGIN_MSG_MAP等宏定义来简化窗口类、窗口函数的编码;

2、采用GDI/GDI++进行自绘

缺点:

1、GDI++绘图速度较慢,所以只在绘制特定格式的图片资源、和绘制文字时使用GDI++;

2、由于整个开始菜单窗口的结构为一层层的父窗口嵌套子窗口,导致难以在一次WM_PAINT消息分发中完成所有控件的绘制。这就导致了随机区域的闪烁问题。

改进

1、业界通用做法,使用双缓冲技术,先绘制到内存DC,绘制操作全部完成后然后再将其BitBlt到硬件DC上;

2、将不必要的重绘降到最低,用空间换时间。每次绘制完成后,保留该内存DC,以“按钮”控件为例,如果其文本没有改变、鼠标的hover状态也没有改变,此时如果收到WM_PAINT消息,将直接把上次绘制好的内存DC给BitBlt到硬件DC上,结束OnPaint流程。

通过双缓冲、“懒”渲染的方式,尽可能将子窗口绘制潜在闪烁问题的影响降到最低。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool bHoldLast = !(!m_hMemDCCache);
if (!m_hMemDCCache)
{
m_hMemDCCache = CreateCompatibleDC(hdc);
}
if (!m_hBmpBknd)
{
m_hBmpBknd = CreateCompatibleBitmap(hdc, rtClient.Width(), rtClient.Height());
}
if (m_bLazyRender)
{
if (m_bLastHover == m_bHover && m_LastText == m_text && bHoldLast)
{
BitBlt(hdc, rtClient.left, rtClient.top, rtClient.right, rtClient.bottom, m_hMemDCCache, 0, 0, SRCCOPY);
if (uMsg!=WM_PRINTCLIENT)
EndPaint(&ps);
return 0;
}
else
{
bHoldLast = false;
}
}

二、AERO

做法

1、Win 8 以来微软虽然移除了AERO毛玻璃的功能,但Win 10 自己的开始菜单却拥有毛玻璃效果。如果你的机器足够慢,能够观察到Win 10开始菜单弹出的时候实际上是半透明而不是毛玻璃,在窗口弹出一段时间后背景才变成毛玻璃效果;

2、经过查阅相关资料,微软在user32.dll中有一个未公开的API:SetWindowCompositionAttribute,将窗口句柄和混合策略传给它,能对任意窗口开启毛玻璃效果。

缺点

1、这个API虽然好用,但直接将窗口背景的渲染交给了系统完成。播放视频时,窗口背景会随下层视频内容的变化而变化,而毛玻璃效果不受影响。

2、GDI提供的文字绘制、UXTheme提供的文字绘制均和这个API不兼容,绘制出来的文字自带白色背景无法消除。所以此时只能使用GDI++来进行文字绘制,而GDI++绘制的文字受毛玻璃效果影响,边缘模糊不清楚。

改进

1、在尝试UXTheme、GDI自带的文字绘制无果后,转而采用GDI++绘制,并且设置文本Hint策略为TextRenderingHintAntiAlias。这样绘制出来的效果自带抗锯齿,但边缘扔有模糊问题。

2、对字体进行描边绘制,绘制时分别上左、上右、下左、下右 1px的位置绘制灰色文本,最后在中央绘制白色文本,来达到描边的效果,增强文字的可读性。

通过SetWindowCompositionAttribute这套API,为开始菜单打开了毛玻璃的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ACCENTPOLICY
{
int nAccentState;
int nFlags;
int nColor;
int nAnimationId;
};
struct WINCOMPATTRDATA
{
DWORD attribute;
PVOID pData;
ULONG dataSize;
};
typedef BOOL (WINAPI *_SETWINDOWCOMPOSITIONATTRIBUTE)(HWND, WINCOMPATTRDATA*);

_SETWINDOWCOMPOSITIONATTRIBUTE m_SetWindowCompositionAttribute;
m_SetWindowCompositionAttribute = (_SETWINDOWCOMPOSITIONATTRIBUTE)GetProcAddress(GetModuleHandle(_T("user32.dll")), "SetWindowCompositionAttribute");

if (m_SetWindowCompositionAttribute)
{
ACCENTPOLICY policy = { 3, 0x13, color, 0 };
WINCOMPATTRDATA data = { 19, &policy, sizeof(ACCENTPOLICY) };
m_SetWindowCompositionAttribute(m_hWnd, &data);
}

三、适配高DPI

做法

1、我们设计了一个全局单例类来管理高DPI的所有行为,以前的代码不需要计算DPI实际值是多少、不需要关心高DPI的问题,只需要将以前hard code的宽高值交给该单例类进行一次缩放处理即可。

2、系统对不感知高DPI的程序,会直接将其界面模糊放大2倍处理。要想向系统声明自己支持高DPI,有两种方法:①直接在manifest中声明;②在进程中调用SetProcessDpiAware或者在线程中调用GetThreadDpiAwarenessContext,这两个函数分别位于User32.DLL和Shcore.DLL中。

缺点

1、窗口尺寸、按钮大小、控件位置都能很好地适应高DPI,在高DPI的显示器上,主界面放大而不模糊,兼顾了美观和易读性。

2、图片资源难以通过直接缩放的方式支持高DPI,直接缩放会造成模糊、不缩放就显得特别小。传统显示器为96dpi,而高DPI的显示器有120、150、200等各种dpi设置。如果为每种缩放比例都配置切图的话,是一个不折不扣的灾难。而直接上200%缩放的切图然后向下缩放,既造成了资源浪费,而且面临300%的缩放比例仍然没有办法,会造成模糊——总会存在一个值卡住你。

改进

1、变像素图为矢量图,GDI++原生支持emf格式的矢量图绘制。然而在具体实施的过程中我们还发现,对于小尺寸的切图,矢量图的效果比不上像素图。原因是像素图在图像边缘部分会有抗锯齿处理,放大之后能发现不是非黑即白、而是有灰度过渡处理的。而矢量图不存在抗锯齿处理,所以向下缩放的时候,仍然需要采用像素图。

2、尝试使用字体图标。由于字体的渲染自带抗锯齿效果,与其苦苦研究emf图片的抗锯齿渲染算法,不如直接将切图变成字体、然后使用字体渲染来获得抗锯齿的加成。这也是Win 10系统自己采用的策略,所有UWP应用均采用了名为“Segoe MDL2 Assets”的字体。其中包含大量的抽象图标,直接复制Unicode码到代码中,载入这款字体、绘制该字符串,直接根据显示器的DPI指定字体大小,即可实现根据各种不同DPI无损缩放Icon的效果。

四个与高DPI支持相关的函数原型:

1
2
3
4
typedef HRESULT (WINAPI *PFN_GetDpiForMonitor) ( HMONITOR hmonitor, MONITOR_DPI_TYPE dpiType, UINT *dpiX, UINT *dpiY);
typedef HRESULT (WINAPI *PFN_SetProcessDpiAwareness) ( PROCESS_DPI_AWARENESS value);
typedef DPI_AWARENESS_CONTEXT (WINAPI *PFN_SetThreadDpiAwarenessContext) (DPI_AWARENESS_CONTEXT dpiContext);
typedef DPI_AWARENESS_CONTEXT (WINAPI *PFN_GetThreadDpiAwarenessContext) (void);

Font Icon 字体:

应用FontIcon 和 对高DPI支持的效果:

四、锁定程序

背景

1、我们知道,Win 7 以来我们可以将程序固定到两个常用位置:任务栏和开始菜单。

2、Win 7 系统上存储固定软件列表的位置在:%APPDATA%\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned\StartMenu这里。所有软件以快捷方式存储在这个目录下。

3、Win 10 系统引入动态磁贴的概念,磁贴包含UWP应用的入口、UWP动态创建的Secondary Tile、和传统的 Win 32 磁贴。由于磁贴布局、尺寸的多变,我们无法像以前那样直接获取固定到开始屏幕的程序了。

预研

1、楼主是powershell爱好者,从Win 10 Build 9xxx加入insider一路升级过来,踩过很多Win 10的坑。Win 10 早期出现过很多开始菜单布局丢失、开始菜单无法弹出的Bug,所以微软为用户准备了一个命令行,可以一键导出开始菜单布局。

2、Export-StartLayout命令可以直接将开始菜单的布局以XML的形式导出到指定目录下。我们通过解析这个XML,可以获取固定到开始屏幕上的Win32程序。如果需要进行相应修改,还可以调整这个XML,然后再导入到系统。

3、powershell命令集在64位系统上无法直接通过ShellExcute的方式调用,所以需要研究一下Export-StartLayout这个命令究竟执行了哪些操作。powershell命令集均为.NET实现,很合楼主的口味 O(∩_∩)O~

以下是楼主以微薄的知识尝试逆向系统功能的过程,绕了些弯路,还请各位大神轻拍:

要逆向的powershell指令如下:

被导出的开始屏幕布局文件如下:

首先使用process monitor记录一下,执行export-startlayout命令之后都发生了些什么,然后尝试定位到创建.\layout.xml文件的CreateFile操作这里:

.NET 库命名DLL的方式特征很明显,如下这个DLL很可能就是powershell命令集对应的DLL:

使用IL SPY分析这个DLL,可以确定刚才的猜想,这个DLL提供了Export-Startlayout这条命令的实现。从PSCmdLet类继承下来,verb和noun分别就是我们用到的Export和StartLayout,其中ProcessRecord方法会解析命令参数并构建COM对象:

在红框标注出来的位置上,是一个CLSID。很明显这段.NET代码只是一个入口,真正的实现在这个CLSID指向的一个COM组件里。

继续看一下这个DLL的命名空间,就发现了这个接口:

其实看到这里,秘密基本上已经揭开了,直接拿着这个CLSID去注册表里看一下,果然对应上了:

这个DLL在刚才用Process Monitor抓取的日志中也出现,是被Microsoft.Windows.StartLayout.Commands.dll加载进来的。那么基本可以确定是这个DLL完成了真正的导出任务。而我们所需要的CLSID和接口定义,已经可以直接从IL SPY的分析结果中抠出来了。

先在自己的DEMO中定义一下接口,然后小试牛刀:

成功完成了导出任务。

剩下的工作,就是用rapidxml工具去解析这个XML树,拿到我们需要的Win32程序的磁贴数据了。

这个数据不仅包含固定到开始屏幕的Win32程序列表,还包括这些磁贴的顺序、大小、位置,如果以后产品策略有相关的个性化调整,还能利用上这些丰富的信息,做出贴合Win10原生逻辑的设计。

注意rapidxml里有个坑就是,它获得的char*是忽略文件编码的。上述方法导出的是UTF-8编码的文本,所以需要根据编码格式做一次MultiByteToWideChar转换,参数为CP_UTF8。

五、定时关机

做法

1、在开始菜单的电源菜单中,除了普通的关机、睡眠、重启,还有一项就是定时关机,用户能够设定一个三天内的关机时间,接近时给用户一个视觉提示,并在触达该时间时执行关机操作。

2、将定时关机的功能做到了一个隐形的子窗口中,使用Timer计时,其他的有关定时关机的设置窗口通过给它发送消息,来控制定时关机的取消、暂停和继续。在设置好时间、开始计时后,这个窗口不在隐形,而是在开始菜单的右下角显示一个倒数窗。

缺点

定时关机的功能运行在UI线程上,将逻辑做到UI上,会导致定时关机的功能无法独立出来。

总结

更好的做法是新建一个线程,运行一个消息循环。与定时关机取消、暂停、继续、读写注册表等操作均在这个线程完成。

用户在界面上产生操作后,发消息给它。而不是将一个包含UI逻辑的子窗口,作为定时关机逻辑的控制中心。

六、Shell扩展

背景

我们的开始菜单需要以高优先级的方式启动,完成注入并替换掉Win10自带的开始菜单。

但依然有些场景下会导致替换失效,比如作为启动项之一,如果我们启动时explorer.exe还没有启动起来,会导致替换失败;而explorer.exe如果意外重启了,我们也不能收到通知,完成二次注入。

总结

有两种方式解决这个问题,①持续监控explorer.exe进程的启动行为,一旦有新创建的explorer.exe,我们就启动并注入;②让explorer.exe主动加载我们,而不是我们一直在后台监控explorer。

第二种方式,实际上就是Shell扩展。我们实现了两种Shell扩展的接口:右键菜单扩展、和图标扩展。这两种扩展可以在explorer拉起时自动加载,也就实现了即便explorer意外重启,我们也能够随时保持经典开始菜单替换的有效性。

如下为我们的Shell扩展实现的接口:

1
2
3
4
5
6
7
8
9
BEGIN_COM_MAP(CCQMStartShellExtension)
COM_INTERFACE_ENTRY(ICQMStartShellExtension)
COM_INTERFACE_ENTRY(IDispatch)
COM_INTERFACE_ENTRY_IID(IID_IShellExtInit, IShellExtInit)
COM_INTERFACE_ENTRY_IID(IID_IContextMenu, IContextMenu)
COM_INTERFACE_ENTRY_IID(IID_IContextMenu2, IContextMenu2)
COM_INTERFACE_ENTRY_IID(IID_IContextMenu3, IContextMenu3)
COM_INTERFACE_ENTRY(IShellIconOverlayIdentifier)
END_COM_MAP()

另外还发现使用rgs文件以声明式的语法,来告诉系统你要注册到哪里、CLSID和IID都是啥。还能指定是否在卸载你的Shell扩展时一并抹掉。以前都是手动写注册表的,使用rgs还是很方便的。

此外,在最近的工作中,把开始菜单剥离出来,作为独立包安装和卸载。也会上架软件管理,以全新的姿态迎接Win 10创造者更新,blah blah blah ~

以上。