MAUI Blazor 顯示本地圖片的新思路

2023-07-22 21:00:24

前言

好久沒寫文章了,水一篇

關於MAUI Blazor 顯示本地圖片這個問題,有大佬發過了。

就是 token 大佬的那篇

Blazor Hybrid (Blazor混合開發)更好的讀取本地圖片

主要思路就是讀取本地圖片,通過C#與JS互操作,將byte[]傳給js,生成blob,圖片的src中填寫根據blob生成的url。

我之前一直使用這個辦法,簡單的優化了一下,無非也就是增加快取。

但是這種方法的弊端也是很明顯的:

  1. img的src每一次並不固定,需要替換

  2. 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.csMainPage.xaml.MaciOS.csMainPage.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 WINDOWS
     path = Uri.UnescapeDataString(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 了

有需要的可以去看一下

Github: https://github.com/Yu-Core/MauiBlazorLocalImage

Gitee: https://gitee.com/Yu-core/MauiBlazorLocalImage