前言
好久没写文章了,水一篇
关于MAUI Blazor 显示本地图片这个问题,有大佬发过了。
就是 token 大佬的那篇
Blazor Hybrid (Blazor混合开发)更好的读取本地图片
主要思路就是读取本地图片,通过C#与JS互操作,将byte[]传给js,生成blob,图片的src中填写根据blob生成的url。
我之前一直使用这个办法,简单的优化了一下,无非也就是增加缓存。
但是这种方法的弊端也是很明显的:
-
img的src每一次并不固定,需要替换
-
Android端加载体积比较大的图片的速度,特别特别慢
所以有没有一种办法能够解决这两个问题
思考了很久,终于有了思路,
拦截网络请求/响应,读取本地文件并返回响应
搜索了一下,C#/MAUI中没有太好的拦截办法,只能从Webview下手
理论已有,实践开始
准备工作
新建一个MAUI Blazor项目
参考 配置基于文件名的多目标 ,更改项目文件(以.csproj结尾的文件),添加以下代码
<!-- Android -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net7.0-android')) != true">
<Compile Remove="**\**\*.Android.cs" />
<None Include="**\**\*.Android.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Both iOS and Mac Catalyst -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net7.0-ios')) != true AND $(TargetFramework.StartsWith('net7.0-maccatalyst')) != true">
<Compile Remove="**\**\*.MaciOS.cs" />
<None Include="**\**\*.MaciOS.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- iOS -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net7.0-ios')) != true">
<Compile Remove="**\**\*.iOS.cs" />
<None Include="**\**\*.iOS.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Mac Catalyst -->
<ItemGroup Condition="$(TargetFramework.StartsWith('net7.0-maccatalyst')) != true">
<Compile Remove="**\**\*.MacCatalyst.cs" />
<None Include="**\**\*.MacCatalyst.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
<!-- Windows -->
<ItemGroup Condition="$(TargetFramework.Contains('-windows')) != true">
<Compile Remove="**\*.Windows.cs" />
<None Include="**\*.Windows.cs" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
</ItemGroup>
分别添加MainPage.xaml.Android.cs
,MainPage.xaml.MaciOS.cs
,MainPage.xaml.Windows.cs
MainPage.xaml.cs
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
blazorWebView.BlazorWebViewInitializing += BlazorWebViewInitializing;
blazorWebView.BlazorWebViewInitialized -= BlazorWebViewInitialized;
}
private partial void BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e);
private partial void BlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e);
}
MainPage.xaml.Android.cs,MainPage.xaml.MaciOS.cs,MainPage.xaml.Windows.cs
public partial class MainPage
{
private partial void BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e)
{
}
private partial void BlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e)
{
}
}
Android
https://github.com/dotnet/maui/issues/11382
从这个issue中找到了拦截请求的办法
在ShouldInterceptRequest中添加请求不到时的一些处理。
因为这里填写的,是图片文件的本机绝对路径,安卓中的文件路径是符合浏览器url格式的,所以会被视为基于 https://0.0.0.0
这个基地址的相对路径去发起请求。
当然,它是请求不到的,因为压根就不存在。
所以我们去判断该路径的文件是否存在,存在就读取文件,返回一个新的响应。
注意,不是任意文件都可以的,你的App要对这个文件有访问权限。
MainPage.xaml.Android.cs
using Android.Webkit;
using Microsoft.AspNetCore.Components.WebView;
using Microsoft.AspNetCore.Components.WebView.Maui;
namespace MauiBlazorLocalImage
{
public partial class MainPage
{
private partial void BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e)
{
}
private partial void BlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e)
{
e.WebView.SetWebViewClient(new MyWebViewClient(e.WebView.WebViewClient));
}
private class MyWebViewClient : WebViewClient
{
private WebViewClient WebViewClient { get; }
public MyWebViewClient(WebViewClient webViewClient)
{
WebViewClient = webViewClient;
}
public override bool ShouldOverrideUrlLoading(Android.Webkit.WebView view, IWebResourceRequest request)
{
return WebViewClient.ShouldOverrideUrlLoading(view, request);
}
public override WebResourceResponse ShouldInterceptRequest(Android.Webkit.WebView view, IWebResourceRequest request)
{
var resourceResponse = WebViewClient.ShouldInterceptRequest(view, request);
if (resourceResponse == null)
return null;
if (resourceResponse.StatusCode == 404)
{
var path = request.Url.Path;
if (File.Exists(path))
{
string mime = MimeTypeMap.Singleton.GetMimeTypeFromExtension(Path.GetExtension(path));
string encoding = "UTF-8";
Stream stream = File.OpenRead(path);
return new(mime, encoding, stream);
}
}
//Debug.WriteLine("路径:" + request.Url.ToString());
return resourceResponse;
}
public override void OnPageFinished(Android.Webkit.WebView view, string url)
=> WebViewClient.OnPageFinished(view, url);
protected override void Dispose(bool disposing)
{
if (!disposing)
return;
WebViewClient.Dispose();
}
}
}
}
下面做一个小例子
用MAUI的 MediaPicker.Default.PickPhotoAsync
去选择图片
这里不做过多的处理,Android中选择图片得到的路径实际上是复制到App的Cache文件夹下的图片文件路径
App对自己的FileSystem.Current.AppDataDirectory和FileSystem.Current.CacheDirectory这两个文件夹是有完全的读写权限的。
这里不做过多解释
Pages/Index.razor
@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<div>
<img src="@photoPath" style="max-width: 100%;" />
</div>
<div style="word-wrap: break-word;">
@photoPath
</div>
<div>
<button @onclick="PickPhoto">PickPhoto</button>
</div>
@code
{
string photoPath;
private async Task PickPhoto()
{
var fileResult = await MediaPicker.Default.PickPhotoAsync();
var path = fileResult?.FullPath;
if (path is null)
{
return;
}
photoPath = path;
await InvokeAsync(StateHasChanged);
}
}
看一下效果
(下面调试工具这个截图是后补的,所以路径不一致,忽略这些细节)
由此可以看到,已经成功拦截,并且把响应换成了我们自己创建的。
换一张大一点的图片,看看速度
特意选了一张13.28 MB的4k图片,速度还可以
Windows
在之前那个issue https://github.com/dotnet/maui/issues/11382 中,并没有关于Windows如何拦截Webview请求的方法。
Windows上的Webview是使用的微软自家的WebView2。
于是我在Webview2的官方文档中找到了这个
但有个难题,Windows上的文件路径不符合浏览器url格式,它会被视为文件请求自动变成file:///
开头的路径
file:///
开头的路径是请求不到的,这里不过多解释。
所以我们在使用Windows上的文件路径之前,先把它转义一下 Uri.EscapeDataString()
等到拦截请求后,再变回去 Uri.UnescapeDataString()
MainPage.xaml.Windows.cs
using Microsoft.AspNetCore.Components.WebView;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Storage.Streams;
namespace MauiBlazorLocalImage
{
public partial class MainPage
{
private partial void BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e)
{
}
private partial void BlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e)
{
var webview2 = e.WebView.CoreWebView2;
webview2.WebResourceRequested += async (sender, args) =>
{
string path = new Uri(args.Request.Uri).AbsolutePath.TrimStart('/');
path = Uri.UnescapeDataString(path);
if (File.Exists(path))
{
using var contentStream = File.OpenRead(path);
IRandomAccessStream stream = await CopyContentToRandomAccessStreamAsync(contentStream);
var response = webview2.Environment.CreateWebResourceResponse(stream, 200, "OK", null);
args.Response = response;
}
};
//为什么这么写?我也不知道,Maui源码就是这么写的
async Task<IRandomAccessStream> CopyContentToRandomAccessStreamAsync(Stream content)
{
using var memStream = new MemoryStream();
await content.CopyToAsync(memStream);
var randomAccessStream = new InMemoryRandomAccessStream();
await randomAccessStream.WriteAsync(memStream.GetWindowsRuntimeBuffer());
return randomAccessStream;
}
}
}
}
例子中的路径也要处理一下
Pages/Index.razor
var fileResult = await MediaPicker.Default.PickPhotoAsync();
var path = fileResult?.FullPath;
if (path is null)
{
return;
}
#if WINDOWS
path = Uri.EscapeDataString(path);
#endif
看一下效果
(这个截图也是后补的,所以路径不一致,忽略这些细节)
iOS / mac OS
在这篇文章最开始写的时候,笔者并没有找到iOS / mac OS中如何拦截请求
本来已经要放弃了,但天无绝人之路
抱着严谨的态度,又做了一些努力,看 Maui 源码,看 issue
克服了种种困难之后,终于有办法了
MainPage.xaml.Windows.cs
using Foundation;
using Microsoft.AspNetCore.Components.WebView;
using System.Runtime.Versioning;
using WebKit;
namespace MauiBlazorLocalImage
{
public partial class MainPage
{
private partial void BlazorWebViewInitializing(object sender, BlazorWebViewInitializingEventArgs e)
{
e.Configuration.SetUrlSchemeHandler(new MySchemeHandler(), "myfile");
}
private partial void BlazorWebViewInitialized(object sender, BlazorWebViewInitializedEventArgs e)
{
}
private class MySchemeHandler : NSObject, IWKUrlSchemeHandler
{
[Export("webView:startURLSchemeTask:")]
[SupportedOSPlatform("ios11.0")]
public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask)
{
if (urlSchemeTask.Request.Url == null)
{
return;
}
var path = urlSchemeTask.Request.Url?.Path ?? "";
if (File.Exists(path))
{
byte[] bytes = File.ReadAllBytes(path);
using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, 200, "HTTP/1.1", null);
urlSchemeTask.DidReceiveResponse(response);
urlSchemeTask.DidReceiveData(NSData.FromArray(bytes));
urlSchemeTask.DidFinish();
}
}
[Export("webView:stopURLSchemeTask:")]
public void StopUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask)
{
}
}
}
}
iOS / mac OS中不能拦截 http 和 https 协议,但是可以拦截自定义协议
所以我们这里添加一个自定义协议 myfile
(不能用file,因为已经被注册过了,被注册过的协议在这里是不能设置的)
实际上,iOS / mac OS中,页面的协议头也是自定义的 app
协议,而不是像windows或Android中的https
例子中的路径也要处理一下
Pages/Index.razor
var fileResult = await MediaPicker.Default.PickPhotoAsync();
var path = fileResult?.FullPath;
if (path is null)
{
return;
}
#if WINDOWS
path = Uri.EscapeDataString(path);
#elif IOS || MACCATALYST
path = "myfile://" + path;
#endif
看一下效果
mac OS
iOS
mac上的浏览器开发者工具最近有bug,用不了,所以就没有开发者工具的截图了
cannot use developer tools to debug blazor hybrid MAUI application in Mac OS
后记
虽然已经基本实现了最开始的目标,不过受限于笔者水平,可能还是不够完美。
文章到这里就结束了,感谢你的阅读
源码地址
本文中的例子的源码放到 Github 和 Gitee 了
有需要的可以去看一下