- 背景
平時維護一個Winform系統,前段時間公司提出所有系統都要加水印,也就有了這個文章內容。我將寫出我一路的想法、碰到的問題還有最后的解決方案。
- 網絡調研
其實我們團隊除了這個Winform客戶端,還有一些Web后台,當然他們也是要加水印的。所以我一開始參考web怎么繪制水印,核心代碼大致如下:
1 //創建一個畫布 2 var can = document.createElement('canvas'); 3 //設置畫布的長寬 4 can.width = 120; 5 can.height = 120; 6 7 var cans = can.getContext('2d'); 8 //旋轉角度 9 cans.rotate(-25 * Math.PI / 180); 10 cans.font = 'lighter 12px PingFang SC'; 11 12 //設置填充繪畫的顏色、漸變或者模式 13 cans.fillStyle = 'rgba(150, 150, 150, 0.3)'; 14 //設置文本內容的當前對齊方式 15 cans.textAlign = 'left'; 16 //設置在繪制文本時使用的當前文本基線 17 cans.textBaseline = 'Middle'; 18 //在畫布上繪制填色的文本(輸出的文本,開始繪制文本的X坐標位置,開始繪制文本的Y坐標位置) 19 cans.fillText(str, can.width / 8, can.height / 2); 20 21 var div = document.createElement('div'); 22 div.style.pointerEvents = 'none'; 23 div.style.position = 'fixed'; 24 div.style.zIndex = '100000'; 25 div.style.width = document.documentElement.clientWidth + 'px'; 26 div.style.height = document.documentElement.clientHeight + 'px'; 27 div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat'; 28 document.body.appendChild(div);
備注很清晰,主要就是用畫布繪制了斜向的水印文字,設置透明度,設置事件透傳,最后用這個這個畫布填充body最外層的div中。
於是,我花了很大的精力來搜索、實現Winform控件的透明和事件透傳,如果能實現,我們只需要在主窗體和彈框上,加一個最上層且鋪滿整個窗體的panel就好。很遺憾,這個我沒實現,也沒搜到類似的方案。
當然,網上尋找解決方案時,也會很大收獲,比如 代碼分享:給窗體添加水印 - 陳恩點 - 博客園 (cnblogs.com) ,這篇博客就提供了使用透明無框窗體來覆蓋的方式,但是兩個窗體並無從屬關系,只能把水印窗體設置為topmost,用原窗體的大小來控制水印窗體的大小,導致alt+tab進行程序切換時,水印窗體會展示在整個屏幕的最前面。我試過用WndProc來判斷主窗體是否被遮擋,一個是不好做,一個是部分遮擋情況下水印窗體不好處理。放棄此方案。
- 自行繪制
沒有捷徑可走,也只能擼袖子上了。一開始,我注意到控件繪制時,會調用OnPaint方法,那么我們是不是可以在這個方法結束后,在這個控件上繪制水印呢?而且這個方法是Control類定義的protected方法,是所有控件都會擁有的方法。
1 class XYPanel : System.Windows.Forms.Panel 2 { 3 protected override void OnPaint(System.Windows.Forms.PaintEventArgs e) 4 { 5 base.OnPaint(e); 6 7 var g = e.Graphics; 8 g.SmoothingMode = SmoothingMode.AntiAlias; 9 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 10 //平移畫布到需要畫印章的位置 11 g.TranslateTransform(0, 75); 12 //逆時針旋轉30度 13 g.RotateTransform(-30); 14 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 15 { 16 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 17 { 18 //畫上文字 19 g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x, y); 20 } 21 } 22 } 23 }
base.OnPaint(e);這句,確保了控件本身的繪制已經完成,我們再執行水印的繪制。使用這個方法的另一個優勢是,我們不需要知道何時會觸發水印繪制(拖動?尺寸變更?),反正只要觸發了控制繪制,自然就會重新繪制水印。可以看一下效果圖:
- 坐標系的旋轉
上面的截圖是有問題的,我們一眼就能看到窗體的左下角和右下角是沒有水印的。至於原因,我們想想就能猜到是畫布旋轉導致的。可以做一個簡單的測試:
1 //畫上文字 2 //g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x, y); 3 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height);
我們把上面繪制水印文字的部分改成填充整個畫布以顏色。可以清晰的看到畫布的區域了:
我們來討論一下怎樣讓水印布滿整個窗口。
首先想到的肯定是不進線畫布的旋轉,使用水平的水印。那么有沒有更好的辦法呢?
其實是有的,水印文字繪制的起始位置跟隨畫布逆時針旋轉了30,那么我們通過平面坐標系的旋轉公式再進行一次順時針的旋轉就好了。這個公式網絡上一搜就能找到,而且會有證明過程。當然,我們使用這個公式的結果就行了,詳情請參考 坐標系旋轉變換公式圖解 - 莫水千流 - 博客園 (cnblogs.com):
x' = x cosA - y sinA
y' = x sinA + y cosA
其中A為從x軸正半軸轉向y軸正半軸的角度。那么我水印在窗體中順時針旋轉的角度是 30 度還是 -30 度呢?Winform右為X軸正方向,下為Y軸正方形,所以我們旋轉是從X軸正半軸轉向Y軸正半軸,所以旋轉的角度是 30,所以我們優化一下上面的繪制代碼
1 class XYPanel : System.Windows.Forms.Panel 2 { 3 const float cos30 = 0.866f; 4 const float sin30 = 0.5f; 5 protected override void OnPaint(System.Windows.Forms.PaintEventArgs e) 6 { 7 base.OnPaint(e); 8 9 var g = e.Graphics; 10 g.SmoothingMode = SmoothingMode.AntiAlias; 11 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 12 //平移畫布到需要畫印章的位置 13 g.TranslateTransform(0, 75); 14 //逆時針旋轉30度 15 g.RotateTransform(-30); 16 // 繪制畫布區域 17 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height); 18 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 19 { 20 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 21 { 22 // 計算文字起點位置 23 float x1 = cos30 * x - sin30 * y; 24 float y1 = sin30 * x + cos30 * y; 25 //畫上文字 26 g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 27 } 28 } 29 } 30 }
為了方便對比,我繪制了畫布區域,同時調整了水印的顏色:
可以看到,水印和窗體展示區域已經完全一致了。
- 多控件的契合
首先考慮一個問題,OnPaint方法是受保護的,那么我們一個Winform程序使用了幾十個控件,是不是都要寫一個繼承類並實現OnPaint呢?這顯然不是一個好辦法。好在我發現了一個事件Paint,這也是在控件繪制時觸發的。這樣,我們就不再需要繼承控件類,重寫OnPaint方法。只需要為需要繪制水印的控件綁定Paint事件!
比如我們要實現上面的效果,只需要為原生的Panel來綁定Paint事件:
1 public partial class FormWaterMark : Form 2 { 3 public FormWaterMark() 4 { 5 InitializeComponent(); 6 7 Panel panel1 = new Panel(); 8 panel1.Dock = System.Windows.Forms.DockStyle.Fill; 9 panel1.Paint += xyPanel1_Paint; 10 this.Controls.Add(panel1); 11 } 12 13 void xyPanel1_Paint(object sender, PaintEventArgs e) 14 { 15 const float cos30 = 0.866f; 16 const float sin30 = 0.5f; 17 18 var g = e.Graphics; 19 g.SmoothingMode = SmoothingMode.AntiAlias; 20 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 21 //平移畫布到需要畫印章的位置 22 g.TranslateTransform(0, 75); 23 //逆時針旋轉30度 24 g.RotateTransform(-30); 25 // 繪制畫布區域 26 g.FillRectangle(new SolidBrush(Color.FromArgb(50, 100, 100, 100)), 0, 0, this.Width, this.Height); 27 for (int x = 5; x < e.ClipRectangle.Right + 75; x += 150) 28 { 29 for (int y = 5; y < e.ClipRectangle.Bottom + 75; y += 150) 30 { 31 // 計算文字起點位置 32 float x1 = cos30 * x - sin30 * y; 33 float y1 = sin30 * x + cos30 * y; 34 //畫上文字 35 g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 36 } 37 } 38 } 39 }
到現在為止,我們還只完成了單個控件的水印繪制。一個窗體顯然不止一個控件,如果每個控件都是這么繪制的,那么最終是什么效果呢?
可以看到,展示效果非常凌亂,不成整體。有什么辦法讓這么多畫布契合起來嗎?可以的。我們只要控制畫布的原點一致,那么他們最終繪制效果也會是契合的。所以,我把所有控件的畫布原點都移到窗體原點,然后配置繪制區域足夠包含本控件,就能得到整體一致的水印了:
1 public partial class FormWaterMark : Form 2 { 3 public FormWaterMark() 4 { 5 InitializeComponent(); 6 7 this.panel1.Paint += xyPanel_Paint; 8 this.panel2.Paint += xyPanel_Paint; 9 this.panel3.Paint += xyPanel_Paint; 10 this.panel4.Paint += xyPanel_Paint; 11 } 12 13 void xyPanel_Paint(object sender, PaintEventArgs e) 14 { 15 const float cos30 = 0.866f; 16 const float sin30 = 0.5f; 17 18 var g = e.Graphics; 19 g.SmoothingMode = SmoothingMode.AntiAlias; 20 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 21 22 System.Windows.Forms.Control paintCtrl = sender as System.Windows.Forms.Control; 23 // 計算控件位置 24 int offsetX = 0; 25 int offsetY = 0; 26 while (paintCtrl.Parent != null) 27 { 28 offsetX += paintCtrl.Location.X; 29 offsetY += paintCtrl.Location.Y; 30 paintCtrl = paintCtrl.Parent; 31 } 32 33 // 平移畫布到窗體左上角 34 g.TranslateTransform(0 - offsetX, 0 - offsetY + 32); 35 36 //平移畫布到需要畫印章的位置 37 g.TranslateTransform(0, 75); 38 //逆時針旋轉30度 39 g.RotateTransform(-30); 40 for (int x = 0; x < e.ClipRectangle.Right + 64 + offsetX; x += 128) 41 { 42 for (int y = 0; y < e.ClipRectangle.Bottom + 64 + offsetY; y += 128) 43 { 44 // 計算文字起點位置 45 float x1 = cos30 * x - sin30 * y; 46 float y1 = sin30 * x + cos30 * y; 47 //畫上文字 48 g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), new SolidBrush(Color.FromArgb(50, 255, 0, 0)), x1, y1); 49 } 50 } 51 } 52 }
對比之前的代碼,我們多了一個計算控件偏移量的邏輯,繪制區域也不再是控件大小,而是加上偏移量之后的,展示效果:
可以看到幾個控件的畫布是完全契合的了。為Winform添加水印的功能也就基本完成了。
- 一些優化
上面的最終代碼還有可優化的空間嗎?有的,我們為什么要為每個控件綁定Paint事件呢,要知道一個界面擁有50個控件是非常普遍的。而且以后我們每次新增一個控件,都要為它綁定事件,不利於后續的開發。
所以我做了一個擴展方法,自動為窗口及其子控件綁定Paint事件,代碼如下:
1 public static class ControlExtension 2 { 3 const float cos30 = 0.866f; 4 const float sin30 = 0.5f; 5 public static void BindWaterMark(this Control ctrl) 6 { 7 if (ctrl == null || ctrl.IsDisposed) 8 return; 9 // 繪制水印 10 if (ctrl.HaveEventHandler("Paint", "BindWaterMark")) 11 return; 12 ctrl.Paint += (sender, e) => 13 { 14 System.Windows.Forms.Control paintCtrl = sender as System.Windows.Forms.Control; 15 var g = e.Graphics; 16 g.SmoothingMode = SmoothingMode.AntiAlias; 17 g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias; 18 19 // 計算控件位置 20 int offsetX = 0; 21 int offsetY = 0; 22 while (paintCtrl.Parent != null) 23 { 24 offsetX += paintCtrl.Location.X; 25 offsetY += paintCtrl.Location.Y; 26 paintCtrl = paintCtrl.Parent; 27 } 28 29 // 平移畫布到窗體左上角 30 g.TranslateTransform(0 - offsetX, 0 - offsetY + 32); 31 32 //逆時針旋轉30度 33 g.RotateTransform(-30); 34 35 for (int x = 0; x < e.ClipRectangle.Right + 64 + offsetX; x += 128) 36 { 37 for (int y = 0; y < e.ClipRectangle.Bottom + 64 + offsetY; y += 128) 38 { 39 // 計算文字起點位置 40 float x1 = cos30 * x - sin30 * y; 41 float y1 = sin30 * x + cos30 * y; 42 43 //畫上文字 44 g.DrawString("Watermark", new Font("微軟雅黑", 16, FontStyle.Regular), 45 new SolidBrush(Color.FromArgb(50, 100, 100, 100)), x1, y1); 46 } 47 } 48 }; 49 // 子控件綁定繪制事件 50 foreach (System.Windows.Forms.Control child in ctrl.Controls) 51 BindWaterMark(child); 52 } 53 54 public static bool HaveEventHandler(this Control control, string eventName, string methodName) 55 { 56 //獲取Control類定義的所有事件的信息 57 PropertyInfo pi = (control.GetType()).GetProperty("Events", BindingFlags.Instance | BindingFlags.NonPublic); 58 //獲取Control對象control的事件處理程序列表 59 EventHandlerList ehl = (EventHandlerList)pi.GetValue(control, null); 60 61 //獲取Control類 eventName 事件的字段信息 62 FieldInfo fieldInfo = (typeof(Control)).GetField(string.Format("Event{0}", eventName), BindingFlags.Static | BindingFlags.NonPublic); 63 //用獲取的 eventName 事件的字段信息,去匹配 control 對象的事件處理程序列表,獲取control對象 eventName 事件的委托對象 64 //事件使用委托定義的,C#中的委托時多播委托,可以綁定多個事件處理程序,當事件發生時,這些事件處理程序被依次執行 65 //因此Delegate對象,有一個GetInvocationList方法,用來獲取這個委托已經綁定的所有事件處理程序 66 Delegate d = ehl[fieldInfo.GetValue(null)]; 67 68 if (d == null) 69 return false; 70 71 foreach (Delegate del in d.GetInvocationList()) 72 { 73 string anonymous = string.Format("<{0}>", methodName); 74 //判斷一下某個事件處理程序是否已經被綁定到 eventName 事件上 75 if (del.Method.Name == methodName || del.Method.Name.StartsWith(anonymous)) 76 { 77 return true; 78 } 79 } 80 81 return false; 82 } 83 }
這樣的話,一個Winfrom繪制水印的功能以不到100行代碼的方式完成了。調用也非常方便,在主窗體上 this.BindWaterMark()就好。
- 總結
網絡社區完全找不到解決方案,不知道從和下手,難免會畏難。但到真正做出來,發現代碼挺少的,也沒用什么高級的技術,終究還是要試一試才知道自己行不行。
還是有很多需要完善的,但又有點提不起精力了。比如:
能不能把字體、顏色、透明度配置化?
能不能為某些窗體定制化水印效果?
既然有綁定方法,能不能解綁?
現在不能為動態加載的控件綁定Paint事件,能不能做一個類似於 mainForm.Controls.Changed 的事件來監控動態加載和彈出的窗體?
而且,做的過程中,深感基礎的不足,花了多少時間找到OnPaint,又花了多少時間換到Paint,我已經記不得了,但是想來是不少的。最近做JAVA比較多,有疑問都會習慣性的看看源碼,但是做C#的時候就不會了,哎,得改。