此前有小伙伴詢問我為何他 1 像素的線條顯示發虛,然后我告訴他是“像素對齊”的問題,然而他設置了各種對齊像素的屬性依舊沒有作用。于是我對此進行了一系列試驗,對 WPF 像素對齊的各種方法進行了一次總結。此后在 StackOverflow 中,我回答了 graphics - WPF DrawingContext seems ignore SnapToDevicePixels - Stack Overflow 問題。
閱讀本文,我們將了解解決 WPF 像素對齊的四種方法以及其各自的適用范圍和副作用。
為什么要做像素對齊
看線條!這是 3 像素的線條:
然而論其原因,就是因為我們屏幕太渣~哦~不,是因為繪制的線條沒有與屏幕像素對齊,具體來說是視覺對象(Visual
)的位置不在整數像素上或尺寸不是整數像素。而與此同時屏幕的點距又太大以至于我們看出來繪制的線條和屏幕像素之間的差異。
然而為什么 WPF 不默認為我們對齊像素呢?這是因為要對齊像素必定帶來尺寸上的偏差;這是繪制尺寸精度和最終呈現效果之間的平衡。在 MacBook、Surface Pro 這些高檔顯示屏上,根本不用管這樣的平衡問題;但在渣渣顯示器上,微軟把這種平衡的控制交給了應用的開發者。
處理像素對齊的四種方法
方法一:布局取整 UseLayoutRounding
實際效果是:
根本就不起作用!
事實上我們從 .NET Framework 源碼可以得知,UseLayoutRounding
實際只處理 UI 元素對自己子級控件的布局取整。一旦整棵布局樹種有任何一個不是整數(或者 DPI 相乘后不是整數),那么就依然沒有解決問題。
方法二:對齊設備像素 SnapsToDevicePixels
這是一個會沿著邏輯樹繼承的屬性,只要最頂層設置了這個屬性,里面的元素都會具備此特性。不過,他只處理矩形的渲染,也就是說,只對 Border
Rectangle
這些類型的元素生效,其他的包括自己寫的元素基本都是不管用的。
它有一個好處,是像素對齊的情況下同時能夠保證顯示不足或超過 1 像素時,也能帶一點兒透明或者超過一點像素。
方法三:使用 DrwingContext 繪制并配合 GuidelineSet
如果自己處理繪制,則可以在 OnRender
方法中使用 DrawingContext
來繪制各種各樣的形狀。DrawingContext
有方法 PushGuidelineSet
,而 PushGuidelineSet
就是用來處理對齊的。
以下是四種不同方式的對齊效果對比,其中上面一半是直接對齊(即繪制過程是緊貼著的),下面一半則是多個部分帶上一點偏移(即并不是緊貼):
▲ 看不清的可以考慮方法看
于是要想像素對齊,必須:
- 布局或繪制時,UI 元素之間一點偏移或空隙都不能有,一點都不行
SnapsToDevicePixels
和GuidelineSet
在實際對齊中有效,而UseLayoutRounding
就是在逗你
GuidelineSet
的使用可以參考我在 StackOverflow 上的回答:graphics - WPF DrawingContext seems ignore SnapToDevicePixels - Stack Overflow。
以下是我編寫的用于輔助繪制對齊線條的擴展方法:
public static class SnapDrawingExtensions
{public static void DrawSnappedLinesBetweenPoints(this DrawingContext dc,Pen pen, double lineThickness, params Point[] points){var guidelineSet = new GuidelineSet();foreach (var point in points){guidelineSet.GuidelinesX.Add(point.X);guidelineSet.GuidelinesY.Add(point.Y);}var half = lineThickness / 2;points = points.Select(p => new Point(p.X + half, p.Y + half)).ToArray();dc.PushGuidelineSet(guidelineSet);for (var i = 0; i < points.Length - 1; i = i + 2){dc.DrawLine(pen, points[i], points[i + 1]);}dc.Pop();}
}
注意添加到 GuidelineSet
的尺寸不需要是整數,也不需要計算對齊屏幕的位置,只需要隨便指定一個值即可,但相鄰的繪制元素的值需要在 double 級別完全相同,多一點少一點都不行。
在 OnRender
中調用它繪制:
protected override void OnRender(DrawingContext dc)
{// Draw four horizontal lines and one vertical line.// Notice that even the point X or Y is not an integer, the line is still snapped to device.dc.DrawSnappedLinesBetweenPoints(_pen, LineThickness,new Point(0, 0), new Point(320, 0),new Point(0, 40), new Point(320, 40),new Point(0, 80.5), new Point(320, 80.5),new Point(0, 119.7777), new Point(320, 119.7777),new Point(0, 0), new Point(0, 120));
}
方法四:RenderOptions.EdgeMode
這是純渲染級別的附加屬性,對所有 UI 元素有效。這個屬性很神奇,一旦設置,元素就再也不會出現模糊的邊緣了,一定是硬像素邊緣。不足半像素的全部刪掉,超過半像素的變為 1 個像素。
以為它可以解決問題?——Too young, too simple.
你希望能夠繪制 1 像素的線條,實際上它會讓你有時看得見 1 像素線條,有時看的是 2 像素線條,有時居然完全看不見!!!
如果你都作用對象上還有其它視覺對象,它們也會一并變成了“硬邊緣”,是可以看得見一個個像素的邊緣。
各種方法適用范圍總結
- 如果畫粗線條粗邊框,那么
RenderOptions.EdgeMode
最適合了,因為設置起來最方便,可以設置到所有的 UI 元素上。由于邊框很粗,所以多一個少一個像素用戶也注意不到。 - 如果是畫細邊框,那么使用
Border
配合SnapsToDevicePixels
可以解決,無論是 0.8 像素還是 1.0 像素,1.2 像素,都能在準確地顯示其粗細的基礎之上還保證像素對齊。 - 如果圖形比較復雜,比如繪制表格或者其它各種交叉了線條的圖形,那么使用
DrawingContext
繪制,并設置GuidelineSet
對齊。 - 如果窗口非常簡單,既沒有縮放,UI 元素也不多,可以考慮使用
UseLayoutRounding
碰碰運氣,萬一界面簡單到只需要整數對齊就夠了呢? - 特別說明,上面四種方法不足與應對所有的像素對齊情況,如果還是沒辦法對齊……節哀把……我們一起找偏方……