前言 大概3個星期之前立項, 要做一個 CEF+Blazor+WinForms 三合一到同一個進程的客戶端模板. 這個東西在五一的時候做出了原型, 然後慢慢修正, 在5天之前就上傳到github了. 地址 : https://github.com/BlazorPlus/BlazorCefApp 但是 ...
前言
大概3個星期之前立項, 要做一個 CEF+Blazor+WinForms 三合一到同一個進程的客戶端模板.
這個東西在五一的時候做出了原型, 然後慢慢修正, 在5天之前就上傳到github了.
地址 : https://github.com/BlazorPlus/BlazorCefApp
但是一直在忙各種東西, 沒有時間寫博客.
情況
情況是這麼一個情況 , 這個東西能運行, 夠用. 也寫了7個例子. 離當初的目標還有一些距離. 需要更多的時間去填坑.
CEF方面, 是按需包裝, 沒有用到的功能是沒處理的. 不過按照原先設想, 大部分人都不會有去定製這個CEF的需要.
測試
看這篇博文的網友, 如果不想從github下載編譯, 從 http://opensource.spotify.com/ 另行下載 CEF 的資源包,
可以直接在微雲上下載已經編譯好的版本 : https://share.weiyun.com/oibpnIro
項目模板
如圖, 這是一個標準的 Blazor server side 工程. 有 Program.cs , 有 Startup.cs , 有 Shared/Pages, 有 wwwroot
其中引用的包是 CefLibCore , 源代碼在 https://github.com/BlazorPlus/CefLite , 這個包里有CefLiteCore.dll, 存放著共用的代碼邏輯
Program.cs
using System; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using CefLite; namespace BlazorCefApp { public class Program { [STAThread] static public void Main(string[] args) { //TODO:Change the project type to "Windows Application" to hide the console //If you start the app via Visual Studio , the VS Command Prompt will always show CefWin.PrintDebugInformation = true; //show debug information in console CefWin.ApplicationTitle = "MyBlazorApp"; //as the Default Title CefWin.ShowSplashScreen("wwwroot/splash.jpg"); //or show System.Drawing.Image from embedded resource if (CefWin.ActivateExistingApp()) // Optional, only allow one instance running { Console.WriteLine("Anoter instance is running , So this instance quit."); return; } //CefWin.SettingAutoSetUserDataStoragePath = false; //CefWin.SettingAutoSetCacheStoragePath = false; CefWin.SetEnableHighDPISupport(); CefWin.SearchLibCefSubPathList.Add("chromium"); // search ./chromium/ for libcef.dll CefInitState initState = CefWin.SearchAndInitialize(); if (initState != CefInitState.Initialized) { if (initState == CefInitState.Failed) { System.Windows.Forms.MessageBox.Show("Failed to start application\r\nCheck the github page about how to deploy the libcef.dll", "Error" , System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); } return; } using IHost host = CreateHostBuilder(args).Build(); try { host.Start(); } catch (Exception x) { Console.WriteLine(x); System.Windows.Forms.MessageBox.Show("Failed to start service. Please try again. \r\n" + x.Message, "Error" , System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); CefWin.CefShutdown(); return; } CefWin.ApplicationHost = host; CefWin.ApplicationTask = host.WaitForShutdownAsync(CefWin.ApplicationCTS.Token); ShowMainForm(); CefWin.RunApplication(); } static void ShowMainForm() { string startUrl = aspnetcoreUrls.Split(';')[0]; DefaultBrowserForm form = CefWin.OpenBrowser(startUrl); form.Width = 1120; form.Height = 777; form.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; //CefWin.CenterForm(form); //form.WindowState = System.Windows.Forms.FormWindowState.Maximized; } static string aspnetcoreUrls = "http://127.12.34.56:7890"; //static string aspnetcoreUrls = "http://127.12.34.56:7890;https://127.12.34.56:7891"; //static string aspnetcoreUrls = "https://127.12.34.56:7891"; //Force to SSL , not so useful , just a test //static string aspnetcoreUrls = CefWin.MakeFixedLocalHostUrl(); //make fixed url by user name , so each user can open 1 instance //static string aspnetcoreUrls = CefWin.MakeRandomLocalHostUrl(); //random url allow multiple instance of this app , but cookie/localStorage will lost when open app again. static public IHostBuilder CreateHostBuilder(string[] args) { var builder = Host.CreateDefaultBuilder(args); builder.ConfigureWebHostDefaults(webBuilder => { Console.WriteLine("aspnetcoreUrls : " + aspnetcoreUrls); webBuilder.UseUrls(aspnetcoreUrls); webBuilder.UseStartup<Startup>(); }); return builder; } } }
這是程式入口. 它幹了挺多東西的:
CefWin.PrintDebugInformation = true;
列印一些調試信息到 Console 中去. 如果項目編譯成 Console , 在啟動的時候就會顯示控制台, 能看到一些調試信息.
CefWin.ApplicationTitle = "MyBlazorApp"; //as the Default Title
定義預設標題 , 目前的瀏覽器視窗使用這個標題. 還沒有自動顯示網頁的document.title
CefWin.ShowSplashScreen("wwwroot/splash.jpg");
顯示一個啟動頁面. 自己換掉圖片就可以定製了.
if (CefWin.ActivateExistingApp()) // Optional, only allow one instance running { Console.WriteLine("Anoter instance is running , So this instance quit."); return; }
監測程式是否已經在運行, 如果是的話, 那麼就激活正在運行得程式, 自己退出.
如果想允許程式有多例執行, 那麼就不要這段代碼好了. 但下麵的 static string aspnetcoreUrls 需要制定為動態變化的地址, 以免埠衝突.
//CefWin.SettingAutoSetUserDataStoragePath = false; //CefWin.SettingAutoSetCacheStoragePath = false; CefWin.SetEnableHighDPISupport();
一些選項 , 以後會增加越來越多的定製化選項. 預設情況下, 瀏覽器數據會保存在磁碟里的.
詳細看 https://github.com/BlazorPlus/CefLite/blob/master/CefLite/CefWin.cs 關於 string folder; 那一段:
CefWin.SearchLibCefSubPathList.Add("chromium"); // search ./chromium/ for libcef.dll CefInitState initState = CefWin.SearchAndInitialize();
搜索和啟動CEF , 搜索方法是在指定的子目錄 , 代碼中是 "chromium" 里, 尋找 libcef.dll , 找到就載入.
if (initState != CefInitState.Initialized) { if (initState == CefInitState.Failed) { System.Windows.Forms.MessageBox.Show("Failed to start application\r\nCheck the github page about how to deploy the libcef.dll", "Error" , System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); } return; }
如果狀態不是 Initialized 初始化成功, 那麼有可能是 Failed , 找不到 libcef.dll 或者其他問題, 例如這個exe是32位的, 但是下載的libcef.dll是64位的...
using IHost host = CreateHostBuilder(args).Build(); try { host.Start(); } catch (Exception x) { Console.WriteLine(x); System.Windows.Forms.MessageBox.Show("Failed to start service. Please try again. \r\n" + x.Message, "Error" , System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); CefWin.CefShutdown(); return; }
啟動 Asp.Net Core , Blazor server side
如果啟動失敗, 最有可能是IP埠衝突了.
CefWin.ApplicationHost = host; CefWin.ApplicationTask = host.WaitForShutdownAsync(CefWin.ApplicationCTS.Token); ShowMainForm(); CefWin.RunApplication();
啟動 WinForms , 打開預設瀏覽器, 指向blazor首頁
static void ShowMainForm() { string startUrl = aspnetcoreUrls.Split(';')[0]; DefaultBrowserForm form = CefWin.OpenBrowser(startUrl); form.Width = 1120; form.Height = 777; form.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; //CefWin.CenterForm(form); //form.WindowState = System.Windows.Forms.FormWindowState.Maximized; }
這是啟動這個MainForm的細節. 開發者可以定製一下.
Startup.cs
這個文件很普通, 就是標準的做法便可. 唯一要處理的是註釋掉 app.UseHttpsRedirection() 因為是本地URL無需ssl .
MainLayout.razor
這文件已經被清空了.
因為本例子是 單頁應用程式 , 不需要任何共同的Layout
Index.razor 首頁
@page "/" <div style="text-align:center;padding-top:18px;"> <h2>BlazorCefApp!</h2> <p> <a href="https://github.com/BlazorPlus/BlazorCefApp" target="_blank">https://github.com/BlazorPlus/BlazorCefApp</a> <br /> Run Blazor server side as a window application. <br /> <button class="btn btn-info" style="width:100px" @onclick="()=>BlazorSession.Current.ShowDevTools()">DevTools</button> <button class="btn btn-info" style="width:100px" onclick="window.close()">JSClose</button> <button class="btn btn-info" style="width:100px" @onclick="()=>BlazorSession.Current.CloseBrowser()">CloseForm</button> <button class="btn btn-info" style="width:100px" @onclick="()=>CefLite.CefWin.QuitWindowsEventLoop()">CefQuit</button> @*<button class="btn btn-info" style="width:100px" @onclick="()=>System.Windows.Forms.Application.Exit()">AppExit</button>*@ </p> <hr /> </div> @{ RenderFragment RenderItem<T>(string title, string comment) where T : ComponentBase => @<div class="main-menu-item" @onclick="() => { BlazorSession.Current.ShowDialog<T>(null); }"> <div class="main-menu-item-title">@title</div> <div class="main-menu-item-comment">@comment</div> </div> ; } <div class="main-menu" style="display:flex;flex-direction:row;flex-wrap:wrap;"> @(RenderItem<Demos.Notepad.Notepad>("Notepad","OpenFileDialog and SaveFileDialog")) @(RenderItem<Demos.RegView.RegView>("RegView", "Local Registry and TreeView")) @(RenderItem<Demos.ComPort.ComPort>("ComPort", "Serial Port for Hardware")) @(RenderItem<Demos.ExeInfo.ExeInfo>("ExeInfo", "Show more useful information")) @(RenderItem<Demos.ProcList.ProcList>("ProcList", "Local Process GridView")) @(RenderItem<Demos.PlayMp4.PlayMp4>("PlayMp4", "ActiveX MediaPlayer")) @(RenderItem<Demos.MsTscAx.MsTscAx>("MsTscAx", "ActiveX RemoteDesktop")) </div>
如文章一開始的截圖. 這個頁面的主要作用有
- 提供一個 DevTools 按鈕, 讓開發者可以打開調試工具. 開發者可以自行寫代碼實現不同的方式打開DevTools, 例如熱鍵.
- 提供3種(4種)關閉視窗退出程式的方案. 看情況自己使用.
- 引入 7 種 Demo , Demos.Notepad.Notepad , ......
所有的 demo 都是以 dialog 的方式彈出. 不是URL跳轉.
Notepad 例子
這裡分析一下 Notepad 的做法
HTML:
@inherits DemoDialogBase @inject BlazorSession bses <div class="dialog-content-full" @onkeypress="Dialog_KeyPress"> <div style="display:flex;flex-direction:row;"> <button onclick="history.back()">Back</button> <button @onclick="ShowOpenFileDialog">OpenFileDialog</button> <button @onclick="ShowSaveFileDialog">SaveFileDialog</button> <div style="flex:99999;text-align:center;padding:3px;"> @(currentFilePath==null?"Untitled":System.IO.Path.GetFileName(currentFilePath)) <span style="color:red">@(originalTextCode != currentTextCode?"*":"")</span> </div> @if (currentFilePath != null) { <button @onclick="ExploreCurrentFile">Explore</button> } <button @onclick="SaveCurrentFile">SaveNow(CTRL+S)</button> </div> <BlazorDomTree TagName="textarea" OnRootReady="textarea_ready" spellcheck="false" placeholder="Type text here.." style="width:100%;height:100%;overflow-y:scroll;resize:none" /> </div>
C# :
string currentFilePath; string originalTextCode = ""; string currentTextCode = ""; PlusControl textarea; void textarea_ready(BlazorDomTree bdt) { textarea = bdt.Root; textarea.OnChanging(delegate { currentTextCode = textarea.Value; StateHasChanged(); }); textarea.SetFocus(1); } void WriteToFile(string filepath) { try { System.IO.File.WriteAllText(filepath, currentTextCode); originalTextCode = currentTextCode; } catch (Exception x) { bses.ConsoleError(x.ToString()); bses.Alert("Error", x.Message); } } void ShowOpenFileDialog() { if (string.IsNullOrWhiteSpace(currentTextCode) || originalTextCode == currentTextCode) { ShowOpenFileDialogImpl(); return; } bses.Confirm("Open", "Open another file without saving text?", (result) => { if (result == true) ShowOpenFileDialogImpl(); else textarea.SetFocus(); }); } void ShowOpenFileDialogImpl() { bses.RunBrowser(browser => { var form = browser.FindForm(); using (System.Windows.Forms.OpenFileDialog dialog = new System.Windows.Forms.OpenFileDialog()) { if (currentFilePath != null) dialog.FileName = currentFilePath; dialog.Filter = "Text Files|*.txt"; var res = dialog.ShowDialog(form); if (res != System.Windows.Forms.DialogResult.OK) return; bses.InvokeInRenderThread(delegate { string txt; string openfilepath = dialog.FileName; try { txt = System.IO.File.ReadAllText(openfilepath); } catch (Exception x) { bses.ConsoleError(x.ToString()); bses.Alert("Error", x.Message); return; } currentFilePath = openfilepath; originalTextCode = currentTextCode = txt; textarea.Value = txt; textarea.SetFocus(1); StateHasChanged(); bses.Toast("Load " + System.IO.Path.GetFileName(currentFilePath)); }); } }); } void ShowSaveFileDialog() { bses.RunBrowser(browser => { var form = browser.FindForm(); using (System.Windows.Forms.SaveFileDialog dialog = new System.Windows.Forms.SaveFileDialog()) { if (currentFilePath != null) dialog.FileName = currentFilePath; dialog.Filter = "Text Files|*.txt"; var res = dialog.ShowDialog(form); if (res != System.Windows.Forms.DialogResult.OK) return; bses.InvokeInRenderThread(delegate { string savefilepath = dialog.FileName; try { WriteToFile(savefilepath); } catch (Exception x) { bses.ConsoleError(x.ToString()); bses.Alert("Error", x.Message); return; } currentFilePath = savefilepath; originalTextCode = currentTextCode; textarea.SetFocus(1); StateHasChanged(); bses.Toast("Save " + System.IO.Path.GetFileName(currentFilePath)); }); } }); } void SaveCurrentFile() { if (currentFilePath == null) ShowSaveFileDialog(); else WriteToFile(currentFilePath); } void ExploreCurrentFile() { System.Diagnostics.Process.Start("Explorer", "/select, \""+currentFilePath+"\""); } protected override void OnDialogCancel(string mode) { if (string.IsNullOrWhiteSpace(currentTextCode) || originalTextCode == currentTextCode) { Close(); return; } bses.Confirm("Quit", "Quit without saving text?", (result) => { if (result == true) Close(); else textarea.SetFocus(); }); } void Dialog_KeyPress(KeyboardEventArgs args) { bses.ConsoleLog(System.Text.Json.JsonSerializer.Serialize(args)); if (args.CtrlKey && args.Code == "KeyS") { SaveCurrentFile(); } }
HTML 的代碼挺短的. 它實現了一個簡單佈局.
需要留意的地方:
- Back 按鈕的做法是 history.back() , 純 JavaScript
- 如果是從文件讀來的, 或者已保存為文件, 那麼顯示文件名, 否則顯示 Untitled
- 有 originalTextCode != currentTextCode 的比較, 顯示文件已修改未保存的紅色星星
- 如果有文件名信息, 還提供了 ExploreCurrentFile 的便利 , 這也是與系統進行交互的例子
- 處理了 @onkeypress="Dialog_KeyPress" , 實現 CTRL+S 熱鍵
- 最下麵使用了 BlazorDomTree , 而不是用 InputTextArea , 因為需要在內容在被修改的過程中執行代碼, 而不是等到onchange觸發.
現在回頭分析 C# 代碼:
string currentFilePath; string originalTextCode = ""; string currentTextCode = ""; PlusControl textarea; void textarea_ready(BlazorDomTree bdt) { textarea = bdt.Root; textarea.OnChanging(delegate { currentTextCode = textarea.Value; StateHasChanged(); }); textarea.SetFocus(1); }
BlazorDomTree , PlusControl 是 BlazorPlus 包里的功能. 用於像jQuery一樣寫代碼控制DOM
在
呈現之後, OnRootReady便會執行, textarea=bdt.Root 便可得到這個 Element (<textarea/>) 的 C# 引用.
然後監聽 OnChanging 事件, 任何形式的改變, 例如打字, 黏貼, 刪除等等, 都會觸發, 保存內容到 currentTextCode , 並且執行 StateHasChanged()
這個StateHasChanged 必須要手動調用, 因為這個事件不是 Blazor 的 EventCallback 編譯方式.
void ShowOpenFileDialogImpl() { bses.RunBrowser(browser => { var form = browser.FindForm(); using (System.Windows.Forms.OpenFileDialog dialog = new System.Windows.Forms.OpenFileDialog()) { if (currentFilePath != null) dialog.FileName = currentFilePath; dialog.Filter = "Text Files|*.txt"; var res = dialog.ShowDialog(form); if (res != System.Windows.Forms.DialogResult.OK) return; bses.InvokeInRenderThread(delegate { string txt; string openfilepath = dialog.FileName; try { txt = System.IO.File.ReadAllText(openfilepath); } catch (Exception x) { bses.ConsoleError(x.ToString()); bses.Alert("Error", x.Message); return; } currentFilePath = openfilepath; originalTextCode = currentTextCode = txt; textarea.Value = txt; textarea.SetFocus(1); StateHasChanged(); bses.Toast("Load " + System.IO.Path.GetFileName(currentFilePath)); }); } }); }
使用
bses.RunBrowser(browser => { .... });
來實現兩個效果 :
- 取得一個 ICefWinBrowser browser 對象, 使用 browser.FindForm() 來獲得 WinForm窗體
- 讓 delegate 代碼在 WinForms線程(主線程) 執行 , 而不是 blazor 的 render thread
在 WinForms線程執行時, 便可直接執行 WinForms 代碼了:
var form = browser.FindForm(); using (System.Windows.Forms.SaveFileDialog dialog = new System.Windows.Forms.SaveFileDialog()) { if (currentFilePath != null) dialog.FileName = currentFilePath; dialog.Filter = "Text Files|*.txt"; var res = dialog.ShowDialog(form); if (res != System.Windows.Forms.DialogResult.OK) return;
這是很標準的 SaveFileDialog 流程呀
獲取到要打開的文件路徑後, 要這麼乾 :
bses.InvokeInRenderThread(delegate {
這是從 WinForms 線程 , 切換回 Blazor 的 render 線程
這一點非常重要. Blazor要活在自己的線程, WinForms也要活在自己的線程, 兩者不能搞錯.
處理 ESC/後退 命令 :
前面已經提及到, BACK按鈕是執行 history.back() 的. 如果文件沒保存, 如何阻止返回呢?
protected override void OnDialogCancel(string mode) { if (string.IsNullOrWhiteSpace(currentTextCode) || originalTextCode == currentTextCode) { Close(); return; } bses.Confirm("Quit", "Quit without saving text?", (result) => { if (result == true) Close(); else textarea.SetFocus(); }); }
這裡重寫了 DemoDialogBase.cs 的方法 OnDialogCancel
並且做出了合適的處理.
如果是用戶關閉整個視窗呢?
由於這隻是一個例子, 代碼需要足夠簡單, 所以沒有寫得太詳細.
要解決這個問題, 需要具體工程具體解決.
基本的原理是在 ShowMainForm 的時候就關聯 FormClosed 事件並處理.
在 Notepad.razor 用 RunBrowser 的方式得到 form 並關聯 FormClosed 也可以.
關於發佈方式
把程式發不成單一個exe , 一百多兆, 有好處也有壞處.
實際上這是dotnet自己做的一個打包過程, 運行的時候, 是需要解壓的.. 這個解壓過程要好幾秒..
第二次運行第三次運行就快了.
如果不把dotnetcore打包進去, 那麼客戶端又要另行安裝框架. 為部署增加了多一層麻煩.
還是那一句, 有利有弊的東西, 要自行選擇.
小結
這個項目目前已經打通了 CEF , WinForms , Blazor (Asp.net core) 三者的關係,
並且都在同一個進程, 同一個AppDomain里, 可以直接互相調用.
後面有時間再繼續寫更多的例子.
如有任何問題, 請加QQ群