.NET 桌面程序一般使用传统的 WinForm 或者较新的 WPF 来进行开发。
WinForm的优点是流程简单,即拖即用;但缺点是布局方式较为简陋,维护和更新困难,想要美化界面则需要涉及底层的子类化、或者重写控件绘制。
WPF 曾是微软大力推广的新型UI设计方案,采用XAML(XML)来绑定控件与代码,做到前后端分离,结构上与HTML非常相似。然而微软这种重复造轮子的行为,不仅没能推广WPF,反而因为复杂的学习成本,导致WPF一直处于叫好不叫座的尴尬境地。其实 .NET 早就该拥抱HTML这种通用的界面方案,何必再弄个WPF来脱裤子放屁呢?

其实在 .NET 中应用 Web 界面的方法有不少,最简单的就是使用内部的 WebBrowser 控件,其本身支持通过COM方式与本地代码交互,只不过因为 WebBrowser 使用的是IE内核,无法支持最新的HTML5标准,所以在设计界面时会束手束脚,也无法应用HTML最新的特性。

比较常用的方式是使用第三方的 Chromium 库如 CefSharp,但缺点就是太臃肿了!!!如果你只是写一个小程序,不会想拖个几百兆的 Chromium 运行库进去吧?

另外,微软也推出了 WebView2 组件,用于代替IE内核的 WebBrowser,只不过它需要安装运行库,且内核用的是 Edge 版本的 Chromium 内核,肯定不如原版 Chromium 迅捷。

而除了 CefSharp 和 WebView2 之外的选择,就是我今天要介绍的 Miniblink 了!

目录 - Table of Contents


WebBrowser 如何与 .NET 程序交互

先来介绍一下 WebBrowser 如何与 .NET 交互吧,作为一个参考。
如果你的程序界面并不复杂,并且用不上HTML5的布局功能,使用 WebBrowser 不失为一个好选择。

先推荐一下我分享的 WebBrowserExt 扩展类(VB.NET源码),主要修正了默认的 WebBrowser 的一些错误、添加了一些额外的事件和属性。
默认的 WebBrowser 也可以应用下面的代码,但可能无法拦截部分弹窗事件。

WebBrowser 初始化:
Dim wb As New WebBrowserExt
'Dim wb As New WebBrowser

With wb
    .IsWebBrowserContextMenuEnabled = False    '禁止浏览器右键菜单
    .WebBrowserShortcutsEnabled = False    '禁止键盘快捷键
    .AllowWebBrowserDrop = False '禁止拖动文档
    .DisableNavigationSounds = True    '禁用点击链接的声音,WebBrowser无效

    ' 设置脚本对象,该类型必须是面向COM公开的类型
    .ObjectForScripting = New WebCoreFn()

    ' 加载页面
    .Navigate(Application.StartupPath & "\web.html")
End With
与 WebBrowser 交互的函数库:
Imports System.Runtime.InteropServices

' 核心函数库,将插入到 WebBrowser 中执行
<ComVisible(True)>
Public Class WebCoreFn

    Public Function website() As String
        Return "https://clso.fun"
    End Function

    Public Function version() As String
        Return My.Application.Info.Version.ToString
    End Function

    Public Function add(ByVal i1 As Integer, ByVal i2 As Integer) As Integer
        Return i1 + i2
    End Function

    Public Sub msgbox(ByVal msg As String)
        Microsoft.VisualBasic.MsgBox(msg)
    End Sub
    Public Sub msgbox2(ByVal msg As String, ByVal title As String)
        Microsoft.VisualBasic.MsgBox(msg, , title)
    End Sub
End Class
所有函数库的方法都会被添加到 WebBrowser JS 的 window.external 中去,web.html 部分示例:
<script>
function msgbox(msg,title){
    if(title!=null)
        window.external.msgbox2(msg,title);
    else
        window.external.msgbox(msg);
}
function add(){
    var ret = window.external.add(tb1.value, tb2.value);
    tb3.value = ret;
    msgbox(ret);
}
</script>

<input type="textbox" id="tb1" value="3" /> + 
<input type="textbox" id="tb2" value="4" /> = 
<input type="textbox" id="tb3" /> 
<input type="button" value="计算" onclick="add()" />
WebBrowser 的好处是简单方便,不需要带任何额外的库文件。
但缺点是IE内核不支持HTML5,无法加载虚拟地址,如果需要加载内嵌的资源则需要自己写一个 HttpListener 来作为自服务器。

Miniblink 与 NetMiniblink

Miniblink 是目前最精简、小巧的 Chromium 库,如果针对特定的系统环境,只需要带一个DLL文件即可。其核心库为C接口,所以需要我们自己调用其API。
而 NetMiniblink 则是一个 Miniblink 的 .NET 包装库,并且贴心地帮我们将 Miniblink 包装为了控件,可以直接调用,所以我个人比较推荐使用 NetMiniblink 来对 Miniblink 进行调用和开发。

安装与环境搭建

先下载 Miniblink 最新版的运行库
https://github.com/weolar/miniblink49/releases

本文以 miniblink-20231115 版为例,解压并找到 miniblink_4975_x32.dllminiblink_4975_x64.dll 两个文件,将其重命名为 miniblink_x32.dllminiblink_x64.dll,将其拖放到VS工程中,调整文件属性为 复制到输出目录=始终复制

20231115 版的 miniblink 其实也已经很大了,光64位版的就已经达到了40MB,如果你的程序只想支持64位,则可以在VS的编译选项中设置仅输出64位CPU的版本。
当然,如果你想同时兼容x86和x64,也可以在输出时选择仅面向x86 CPU,同时也只需要带一个34MB的x32类库。

另外,你还可以在这里下载我使用 UPX 压缩后的 miniblink 库,x86的DLL压缩到了11.3MB,x64的也压缩到了14.7MB!
https://www.lanzoub.com/i2QAW20oorwf

之后下载 NetMiniblink 最新版的源码进行编译
https://gitee.com/aochulai/NetMiniblink

这里推荐使用源码进行编译,因其 Releases 版本较为落后,并且源码中我们需要修改不少地方:
  • 修改工程属性的程序集名称和默认命名空间,将 QQ2564874169.Miniblink 改为 NetMiniblink(如果你不在乎这奇怪的命名空间的话则可以忽略)
  • 然后在代码页中全局替换文本 QQ2564874169.MiniblinkNetMiniblinkCtrl+H,范围选择当前项目或者整个解决方案)
  • 修改 Properties\AssemblyInfo.cs,将其中的 QQ2564874169.Miniblink 以及版权声明改为 NetMiniblink 或者删除
  • 修改 MBApi.cs,将其中的 DLL_x86 = "miniblink_x86.dll" 修改为 DLL_x86 = "miniblink_x32.dll"(如果你不想修改代码的话,则需要将32位的DLL文件修改为 miniblink_x86.dll
  • 修改 LocalHttp\NetApiEngine.csname.SW("QQ2564874169") 修改为 name.SW("NetMiniblink")
修改完成后编译,编译出的 NetMiniblink.dll 应该有 2.9MB 左右,这是因为它内嵌了开发者工具以及 DotNetZip.dll 用于对ZIP压缩包的支持。如果你想要缩小此类库的大小,可以尝试禁用 DotNetZip.dll 和 front_end.zip 的内嵌编译。

如果你不希望自己编译,可以使用我分流的的类库,已经包含了完整版以及去除了开发者工具和 DotNetZip 的版本:
https://www.lanzoub.com/iyL0W20oop6h

编译或者下载了 NetMiniblink.dll 之后,只需要在VS工程中引用即可。
NetMiniblink 的 Demo 工程有较为详细的用法示例,我只会在下面列出一些重点来介绍。

Miniblink 的应用范围

Miniblink 是 Chromium 的极度简化版本,只保留了最重要的布局和JS功能。
免费版的 Miniblink 砍掉了媒体功能和多线程渲染,因此并不适合用于专业的网页浏览器,仅适合用作 Web 界面开发。

NetMiniblink 对 Miniblink 有较深的耦合与扩展,除了支持正常的网页、本地HTML内容之外,还支持虚拟域名、请求拦截与改写、程序集资源与ZIP包解析等等。

NetMiniblink 主要使用 MiniblinkBrowserMiniblinkForm 两个控件作为容器,MiniblinkBrowser 就是一个可以正常添加到其他窗体的浏览器控件,而 MiniblinkForm 是内部包含一个名为 View 的 MiniblinkBrowser 的窗体控件,可以设置透明化背景,作为传统Form控件的代替品。

使用 MiniblinkBrowser

用代码初始化一个 MiniblinkBrowser:
Imports NetMiniblink

' 以下所有示例都以 wb 作为浏览器控件的命名
Dim wb As New MiniblinkBrowser
wb.Dock = DockStyle.Fill
Me.Controls.Add(wb)

wb.LoadUri("https://clso.fun")
此段代码可以放置到窗体的 Load 事件中,用于在启动时直接生成一个 MiniblinkBrowser 控件。
我们后面大部分的代码都是在与 MiniblinkBrowser 进行交互。

将内嵌程序集资源映射到虚拟域名中

Imports NetMiniblink
Imports NetMiniblink.ResourceLoader

' 将程序集映射为虚拟空间,参数2指定资源路径,参数3为虚拟的域名
wb.ResourceLoader.Add(New EmbedLoader(Me.GetType.Assembly, "Res", "loc.res"))

wb.LoadUri("http://loc.res/index.html") '访问虚拟域名下的指定文件
EmbedLoader 可以将程序集内嵌资源的某个“文件夹”映射到虚拟的域名中,之后就可以访问这个程序集下的指定资源了。

FileLoader 与 ZipLoader 文件映射

FileLoader 就是简单的将物理文件夹映射到虚拟空间中,而 ZipLoader 可以将一个ZIP压缩文档映射到虚拟空间中。
wb.ResourceLoader.Add(New FileLoader("文件夹路径", "loc.file"))
wb.LoadUri("http://loc.file/index.html") 

' ZipLoader 支持带密码的文档
wb.ResourceLoader.Add(New ZipLoader("ZIP文档路径", "loc.zip", "ZIP文档密码"))
wb.LoadUri("http://loc.zip/index.html") 

' 还支持从程序集内加载ZIP
wb.ResourceLoader.Add(New ZipLoader(Me.GetType.Assembly, "程序集内路径", "ass.zip", "ZIP文档密码"))
wb.LoadUri("http://ass.zip/index.html") 
注意:ZipLoader 需要 DotNetZip.dll 类库的支持,如果你使用的是我编译的精简版本,记得下载一个 DotNetZip.dll 放到程序目录内。

自定义请求返回,实现 ILoadResource 接口

如果你想实现自己的虚拟域名及数据返回,只需要实现 ILoadResource 接口即可:
Imports NetMiniblink
Imports NetMiniblink.ResourceLoader

Class MyLoader
    Implements ILoadResource

    Dim _domain As String

    Sub New(ByVal domain As String)
        _domain = domain
    End Sub

    Public Function ByUri(ByVal uri As System.Uri) As Byte() Implements NetMiniblink.ILoadResource.ByUri
        Dim path = uri.AbsolutePath.Replace("/", IO.Path.DirectorySeparatorChar.ToString())
        ' 根据 path 将文件或资源的数据以 byte() 返回
        Return Nothing
    End Function

    Public ReadOnly Property Domain As String Implements NetMiniblink.ILoadResource.Domain
        Get
            Return _domain
        End Get
    End Property
End Class


' 调用示例
wb.ResourceLoader.Add(New MyLoader("just.for.fun"))
wb.LoadUri("http://just.for.fun/index.html") '因为我们未实现数据返回,所以页面永远是空的
其中 Domain 属性用于匹配虚拟的域名,而 ByUri 用于匹配并返回实际的数据。

使用 MiniblinkForm

一个简单的窗体类实例:
Imports NetMiniblink

Public Class WebForm
    Inherits MiniblinkForm

    Sub New()
        ' 设置为透明窗体模式,False或留空不设置
        MyBase.New(True)

        ' 支持高DPI
        MiniblinkSetting.EnableHighDPISupport()
        ' 让窗体不显示边框
        Me.FormBorderStyle = FormBorderStyle.None
        ' 允许调整窗体大小
        Me.NoneBorderResize = True
        ' 设置窗体阴影范围
        Me.ShadowWidth.SetAll(10)
        ' 允许通过HTML元素拖拽窗体
        Me.DropByClass = True

        ' 加载网页
        Me.View.LoadUri("http://loc.res/index.html")
    End Sub

End Class
MiniblinkForm 可能在部分环境下无法在VS中进行实时渲染,不过调试运行时是正常的。
而窗体的 View 属性就是内嵌在其中的 MiniblinkBrowser 实例。

如果想让 MiniblinkForm 可以被拖动,可以在HTML中为某个元素的Class设置为 mbform-drag,该元素就可以代替标题栏进行拖动了。

注册方法到JS中

' 匿名函数
wb.BindNetFunc(New NetFunc("funcAdd", Function(context)
                                          Return context.Paramters(0) + context.Paramters(1)
                                      End Function))
' 正常的委托
wb.BindNetFunc(New NetFunc("funcSub", AddressOf funcSub))

' NetFunc 的委托原型,context 中包含JS调用时的参数
Function funcSub(ByVal context As NetFuncContext) As Object
    Return context.Paramters(0) - context.Paramters(1)
End Function
我们可以使用匿名函数或者正常的委托方法,将 .NET 的方法注册到浏览器的JS中。
之后,就可以直接在浏览器JS代码内执行 funcAdd(n1,n2)funcSub(n1,n2) 了。

RegisterJsFunc 自动化注册JS函数

MiniblinkBrowser.RegisterJsFunc 是 NetMiniblink 通过反射实施的自动化JS函数注册方法,简化了我们注册JS函数的过程,我们只需要在某个类中,将想要注册的方法设置一个 <JsFunc>[JsFunc] 的元属性,即可让 RegisterJsFunc 帮我们自动注册这个方法到JS中。
Class xxx
    <JsFunc()> Function func1(ByVal n1 As Integer, ByVal n2 As Integer) As Object
        Return "结果是:" & (n1 * n2)
    End Function
    
    ' 可通过 JsFunc 指定注册的名称
    <JsFunc("func2")> Function func123(ByVal n1 As Integer, ByVal n2 As Integer) As Object
        Return "..."
    End Function
End Class

' 自动注册所有包含 JsFunc 描述的方法
wb.RegisterJsFunc(New xxx)
class xxx {
    [JsFunc] object Func1(int n1, int n2){
        return "结果是:" + (n1 * n2);
    }
    [JsFunc("func2")] object Func123(int n1, int n2){
        return "...";
    }
}

wb.RegisterJsFunc(new xxx());

CallJsFunc 调用JS函数

Dim ret1 = wb.CallJsFunc("funcAdd", 1, 2)
Dim ret2 = wb.CallJsFunc("funcSub", 3, 2)
如果调用的JS函数的参数需要JS匿名函数,需要使用 TempNetFunc 类型进行包装。
如果JS函数返回的是动态类型(非.NET原生支持类型),则C#最好使用dynamic进行装载,而VB可以直接用Object装载。

RunJs 直接执行JS代码

wb.RunJs("alert('hello world');")
Dim ret = wb.RunJs("return 1+2;")

拦截与修改请求

当我们请求一个不存在的JS文件,以及一个正常存在的JS文件时:
<!-- 加载一个不存在的JS -->
<script src="notexists.js"></script>
<!-- 加载一个存在的JS -->
<script src="js/hook.js"></script>
' 在请求发生之前的事件
AddHandler wb.RequestBefore, AddressOf wb_RequestBefore

' 处理所有请求
Private Sub wb_RequestBefore(ByVal sender As Object, ByVal e As NetMiniblink.RequestEventArgs)
    ' 请求 notexists.js 时,虽然这个文件并不存在,但我们可以通过
    ' 直接设置 e.Data 来强制返回数据,让这个文件“凭空出现”了
    If e.Url.Contains("notexists.js") Then
        e.Data = Encoding.UTF8.GetBytes("function showName(){alert('this is showName')}")
        Return
    End If
    
    ' 替换正常文件的数据
    If e.Url.Contains("hook.js") Then
        ' 将该文件的正常返回事件绑定到一个匿名函数中
        AddHandler e.Response, Sub(s, res)
                                   ' 获取正常返回的文本数据
                                   Dim js = Encoding.UTF8.GetString(res.Data)
                                   js = js.Replace("name=", "姓名=") '直接替换数据
                                   ' 将替换后的数据返回
                                   res.Data = Encoding.UTF8.GetBytes(js)
                               End Sub
    End If
End Sub
由示例可知,我们可以通过修改 e.Data 的数据,来让请求的数据发生改变,也可以让不存在的文件正常返回数据。
如果想要替换文件数据,则需要绑定该请求的 Response 事件,并在事件激活后再修改数据。

下载事件

' 绑定下载事件
AddHandler wb.Download, AddressOf wb_Download

Private Sub wb_Download(ByVal sender As Object, ByVal e As NetMiniblink.DownloadEventArgs)
    ' 下载进度事件
    AddHandler e.Progress, AddressOf wb_Progress
    ' 下载完成事件
    AddHandler e.Finish, AddressOf wb_Finish

    ' 文件下载路径,如果不设置,则会被暂存到内存中
    ' 可通过 e.Progress 事件获取下载的数据包
    e.FilePath = "文件存储路径"

    ' 取消下载
    e.Cancel = True

    ' 获取文件长度
    Dim length = e.FileLength

    ' 获取下载路径
    Dim url = e.Url
End Sub

Private Sub wb_Progress(ByVal sender As Object, ByVal e As NetMiniblink.DownloadProgressEventArgs)
    ' 让窗体标题显示进度
    Me.Text = String.Format("下载中... {0}%", (e.Received * 1.0 / e.Total * 100))

    ' 取消下载
    e.Cancel = True

    ' 获取当前下载的数据集
    Dim ret = e.Data
End Sub

Private Sub wb_Finish(ByVal sender As Object, ByVal e As NetMiniblink.DownloadFinishEventArgs)
    If e.IsCompleted Then
        MsgBox("下载完成")
        Me.Text = "下载完成"
    Else
        MsgBox("发生错误:" & e.Error.Message)
    End If
End Sub
wb.Download 事件可以监视每个下载发生时,通过设置 e.FilePath 来决定文件的下载位置,或者使用 e.Cancel = True 来取消本次下载。
如果想要监视下载的进度,就需要绑定 e.Progresse.Finish 事件。

NetApiEngine 浅析

NetApiEngine 及相关类型是由 NetMiniblink 自实现的一套自服务系统,使用 HttpListener 作为临时的 Web 服务器,动态解析与返回 HTTP 请求。
而处理类则必须要继承 NetApi,同时其内部方法也需要标记 <Get("/path")><Post("/path")> 等元属性。

虽然 NetApiEngine 很强大,但我个人觉得这套系统使用起来有些繁杂,如果只是简单的改写与Hook请求,使用 RequestBefore 就已经足够了,所以不准备多做介绍了。
我编译的最精简版本 NetMiniblink 就是删除了 NetApiEngine 相关类型的。

如果你有需求,可以查阅 NetMiniblink Demo 内的代码。
暂时先写这么多,有什么不明白或者错误的地点,欢迎大家留言讨论。