[MAUI]整合高德地圖元件至.NET MAUI Blazor專案

林晓lx發表於2024-03-23

@

目錄
  • 前期準備:註冊高德開發者並建立 key
    • 登入控制檯
    • 建立 key
    • 獲取 key 和金鑰
  • 建立專案
    • 建立JS API Loader
    • 配置許可權
    • 建立定義
    • 建立模型
    • 建立地圖元件
    • 建立互動邏輯
  • 專案地址

地圖元件在手機App中常用地理相關業務,如檢視線下門店,設定導航,或選取地址等。是一個較為常見的元件。

在.NET MAUI 中,有兩種方案可以整合高德地圖,一種是使用原生庫繫結。網上也有人實現過:https://blog.csdn.net/sD7O95O/article/details/125827031

但這種方案需要大量平臺原生開發的知識,而且需要對每一個平臺進行適配。

在這裡我介紹第二種方案:.NET MAUI Blazor + 高德地圖JS API 2.0 庫的實現。

JS API 2.0 是高德開放平臺基於WebGL的地圖元件,可以將高德地圖模組整合到.NET MAUI Blazor中的BlazorWebView控制元件,由於BlazorWebView的跨平臺特性,可以達到一次開發全平臺通用,無需為每個平臺做適配。

今天用此方法實現一個地圖選擇器,使用手機的GPS定位初始化當前位置,使用高德地圖JS API庫實現地點選擇功能。混合開發方案涉及本機程式碼與JS runtime的互動,如果你對這一部分還不太瞭解,可以先閱讀這篇文章:[MAUI]深入瞭解.NET MAUI Blazor與Vue的混合開發

.NET MAUI Blazor

使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。

前期準備:註冊高德開發者並建立 key

登入控制檯

登入 高德開放平臺控制檯,如果沒有開發者賬號,請 註冊開發者

在這裡插入圖片描述

建立 key

進入應用管理,建立新應用,新應用中新增 key,服務平臺選擇 Web端(JS API)。再建立一個Web服務型別的Key,用於解析初始位置地址。

在這裡插入圖片描述

獲取 key 和金鑰

建立成功後,可獲取 key 和安全金鑰。

在這裡插入圖片描述

建立專案

新建.NET MAUI Blazor專案,命名AMap

建立JS API Loader

前往https://webapi.amap.com/loader.js另存js檔案至專案wwwroot資料夾

在這裡插入圖片描述

在wwwroot建立amap_index.html檔案,將loader.js引用到頁面中。建立_AMapSecurityConfig物件並設定安全金鑰。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
    <title>AmapApp</title>
    <base href="/" />
    <link href="css/app2.css" rel="stylesheet" />
</head>

<body>

    <div class="status-bar-safe-area"></div>

    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script src="_framework/blazor.webview.js" autostart="false"></script>
    <script src="lib/amap/loader.js"></script>
    <script type="text/javascript">
        window._AMapSecurityConfig = {
            securityJsCode: "764832459a38e824a0d555b62d8ec1f0",
        };
    </script>

</body>

</html>


配置許可權

開啟Android端AndroidManifest.xml檔案

在這裡插入圖片描述

新增許可權:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

開啟Info.plist檔案,新增許可權描述信心

在這裡插入圖片描述

<key>NSLocationWhenInUseUsageDescription</key>
<string>允許使用裝置的GPS更新您的位置資訊。</string>

建立定義

建立Position,Poi,Location等型別,用於描述位置資訊。由於篇幅這裡不展開介紹。

建立模型

建立一個MainPageViewModel類,用於處理頁面邏輯。程式碼如下:

public class MainPageViewModel : ObservableObject
{
    public event EventHandler<FinishedChooiseEvenArgs> OnFinishedChooise;
    private static AsyncLock asyncLock = new AsyncLock();
    public static RateLimitedAction throttledAction = Debouncer.Debounce(null, TimeSpan.FromMilliseconds(1500), leading: false, trailing: true);
    public MainPageViewModel()
    {
        Search = new Command(SearchAction);
        Done = new Command(DoneAction);
        Remove = new Command(RemoveAction);
    }

    private void RemoveAction(object obj)
    {
        this.Address=null;
        this.CurrentLocation=null;
        OnFinishedChooise?.Invoke(this, new FinishedChooiseEvenArgs(Address, CurrentLocation));
    }

    private void DoneAction(object obj)
    {
        OnFinishedChooise?.Invoke(this, new FinishedChooiseEvenArgs(Address, CurrentLocation));

    }

    private void SearchAction(object obj)
    {
        Init();
    }

    public async void Init()
    {
        var location = await GeoLocationHelper.GetNativePosition();
        if (location==null)
        {
            return;
        }
        var amapLocation = new Location.Location()
        {
            Latitude=location.Latitude,
            Longitude=location.Longitude
        };
        CurrentLocation=amapLocation;

    }

    private Location.Location _currentLocation;

    public Location.Location CurrentLocation
    {
        get { return _currentLocation; }
        set
        {

            if (_currentLocation != value)
            {
                if (value!=null &&_currentLocation!=null&&Location.Location.CalcDistance(value, _currentLocation)<100)
                {
                    return;
                }

                _currentLocation = value;
                OnPropertyChanged();
            }
        }
    }

    private string _address;

    public string Address
    {
        get { return _address; }
        set
        {
            _address = value;
            OnPropertyChanged();
        }
    }


    private ObservableCollection<Poi> _pois;

    public ObservableCollection<Poi> Pois
    {
        get { return _pois; }
        set
        {
            _pois = value;
            OnPropertyChanged();
        }
    }

    private Poi _selectedPoi;

    public Poi SelectedPoi
    {
        get { return _selectedPoi; }
        set
        {
            _selectedPoi = value;
            OnPropertyChanged();

        }
    }


    public Command Search { get; set; }
    public Command Done { get; set; }
    public Command Remove { get; set; }

}

注意這裡的Init方法,用於初始化位置。

GeoLocationHelper.GetNativePosition()方法用於從你裝置的GPS模組,獲取當前位置。它呼叫的是Microsoft.Maui.Devices.Sensors提供的裝置感測器訪問功能
,詳情可參考官方文件地理位置 - .NET MAUI

建立地圖元件

建立Blazor頁面AMapPage.razor以及AMapPage.razor.js

AMapPage.razor中引入

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (!firstRender)
        return;
    await JSRuntime.InvokeAsync<IJSObjectReference>(
   "import", "./AMapPage.razor.js");
    await Refresh();
    await JSRuntime.InvokeVoidAsync("window.initObjRef", this.objRef);
}

razor頁面的 @Code 程式碼段中,放置MainPageViewModel屬性,以及一個DotNetObjectReference物件,用於在JS中呼叫C#方法。

@code {
    [Parameter]
    public MainPageViewModel MainPageViewModel { get; set; }
    private DotNetObjectReference<AMapPage> objRef;


    protected override void OnInitialized()
    {
        objRef = DotNetObjectReference.Create(this);
    }

    private async Task Refresh()
    {

        ...
    }

AMapPage.razor.js我們載入地圖,並設定地圖的中心點。和一些地圖掛件。此外,我們還需要監聽地圖的中心點變化,更新中心點。 這些程式碼可以從官方示例中複製。(https://lbs.amap.com/demo/javascript-api-v2/example/map/map-moving)。

console.info("start load")
window.viewService = {
    map: null,
    zoom: 13,
    amaplocation: [116.397428, 39.90923],
    SetAmapContainerSize: function (width, height) {
        console.info("setting container size")

        var div = document.getElementById("container");
        div.style.height = height + "px";

    },
    SetLocation: function (longitude, latitude) {
        console.info("setting loc", longitude, latitude)
        window.viewService.amaplocation = [longitude, latitude];
        if (window.viewService.map) {
            window.viewService.map.setZoomAndCenter(window.viewService.zoom, window.viewService.amaplocation);

            console.info("set loc", window.viewService.zoom, window.viewService.map)
        }
    },
    isHotspot: true

}
AMapLoader.load({ //首次呼叫 load
    key: '0896cedc056413f83ca0aee5b029c65d',//首次load key為必填
    version: '2.0',
    plugins: ['AMap.Scale', 'AMap.ToolBar', 'AMap.InfoWindow', 'AMap.PlaceSearch']
}).then((AMap) => {
    console.info("loading..")
    var opt = {
        resizeEnable: true,
        center: window.viewService.amaplocation,
        zoom: window.viewService.zoom,
        isHotspot: true
    }
    var map = new AMap.Map('container', opt);
    console.info(AMap, map, opt)

    map.addControl(new AMap.Scale())
    map.addControl(new AMap.ToolBar())
    window.viewService.marker = new AMap.Marker({
        position: map.getCenter()
    })
    map.add(window.viewService.marker);
    var placeSearch = new AMap.PlaceSearch();  //構造地點查詢類
    var infoWindow = new AMap.InfoWindow({});
    map.on('hotspotover', function (result) {
        placeSearch.getDetails(result.id, function (status, result) {
            if (status === 'complete' && result.info === 'OK') {
                onPlaceSearch(result);
            }
        });
    });

    map.on('moveend', onMapMoveend);
    // map.on('zoomend', onMapMoveend);
    //回撥函式

    window.viewService.map = map;

    function onMapMoveend() {
        var zoom = window.viewService.map.getZoom(); //獲取當前地圖級別
        var center = window.viewService.map.getCenter(); //獲取當前地圖中心位置
        if (window.viewService.marker) {
            window.viewService.marker.setPosition(center);

        }
        window.objRef.invokeMethodAsync('OnMapMoveend', center);


    }
    function onPlaceSearch(data) { //infoWindow.open(map, result.lnglat);
        var poiArr = data.poiList.pois;
        if (poiArr[0]) {
            var location = poiArr[0].location;
            infoWindow.setContent(createContent(poiArr[0]));
            infoWindow.open(window.viewService.map, location);
        }
    }
    function createContent(poi) {  //資訊窗體內容
        var s = [];
        s.push('<div class="info-title">' + poi.name + '</div><div class="info-content">' + "地址:" + poi.address);
        s.push("電話:" + poi.tel);
        s.push("型別:" + poi.type);
        s.push('<div>');
        return s.join("<br>");
    }


    console.info("loaded")

}).catch((e) => {
    console.error(e);
});
window.initObjRef = function (objRef) {
    window.objRef = objRef;
}

地圖中心點改變時,我們需要使用window.objRef.invokeMethodAsync('OnMapMoveend', center);從JS runtime中通知到C#程式碼。

同時,在AMapPage.razor中配置一個方法,用於接收從JS runtime發來的回撥通知。
在此賦值CurrentLocation屬性。


[JSInvokable]
public async Task OnMapMoveend(dynamic location)
{
    await Task.Run(() =>
     {
         var locationArray = JsonConvert.DeserializeObject<double[]>(location.ToString());
         MainPageViewModel.CurrentLocation=new Location.Location()
             {
                 Longitude=locationArray[0],
                 Latitude =locationArray[1]
             };
     });
}

同時監聽CurrentLocation屬性的值,一旦發生變化,則呼叫JS runtime中的viewService.SetLocation方法,更新地圖中心點。

protected override async Task OnInitializedAsync()
{
    MainPageViewModel.PropertyChanged +=  async (o, e) =>
    {
        if (e.PropertyName==nameof(MainPageViewModel.CurrentLocation))
        {
            if (MainPageViewModel.CurrentLocation!=null)
            {
                var longitude = MainPageViewModel.CurrentLocation.Longitude;
                var latitude = MainPageViewModel.CurrentLocation.Latitude;
                await JSRuntime.InvokeVoidAsync("viewService.SetLocation", longitude, latitude);
            }
        }


    };

}

MainPageViewModel類中,我們新增一個PropertyChanged事件,用於監聽CurrentLocation屬性的改變。

當手指滑動地圖觸發位置變化,導致CurrentLocation屬性改變時,將當前的中心點轉換為具體的地址。這裡使用了高德逆地理編碼API服務(https://restapi.amap.com/v3/geocode/regeo)解析CurrentLocation的值, 還需使用了防抖策略,避免介面的頻繁呼叫。


public MainPageViewModel()
{
    PropertyChanged+=MainPageViewModel_PropertyChanged;
    ...
}



private async void MainPageViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
    if (e.PropertyName == nameof(CurrentLocation))
    {
        if (CurrentLocation!=null)
        {

            // 使用防抖
            using (await asyncLock.LockAsync())
            {

                var amapLocation = new Location.Location()
                {
                    Latitude=CurrentLocation.Latitude,
                    Longitude=CurrentLocation.Longitude
                };
                var amapInverseHttpRequestParamter = new AmapInverseHttpRequestParamter()
                {
                    Locations= new Location.Location[] { amapLocation }
                };
                ReGeocodeLocation reGeocodeLocation = null;
                try
                {
                    reGeocodeLocation = await amapHttpRequestClient.InverseAsync(amapInverseHttpRequestParamter);
                }
                catch (Exception ex)
                {

                    Console.WriteLine(ex.ToString());
                }

                throttledAction.Update(() =>
                {
                    MainThread.BeginInvokeOnMainThread(() =>
                    {
                        CurrentLocation=amapLocation;
                        if (reGeocodeLocation!=null)
                        {
                            Address = reGeocodeLocation.Address;
                            Pois=new ObservableCollection<Poi>(reGeocodeLocation.Pois);

                        }
                    });
                });
                throttledAction.Invoke();
            }
        }
    }
}

至此我們完成了地圖元件的基本功能。

建立互動邏輯

在MainPage.xaml中,建立一個選擇器按鈕,以及一個卡片模擬選擇器按鈕點選後的彈窗。


<Button Clicked="Button_Clicked"
        Grid.Row="1"
        x:Name="SelectorButton"
        HorizontalOptions="Center"
        VerticalOptions="Center"
        Text="{Binding Address, TargetNullValue=請選擇地點}"></Button>


<Border StrokeShape="RoundRectangle 10"
    Grid.RowSpan="2"
    x:Name="SelectorPopup"
    IsVisible="False"
    Margin="5,50"
    MinimumHeightRequest="500">

    <Grid Padding="0">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition Height="Auto"></RowDefinition>
            <RowDefinition></RowDefinition>
        </Grid.RowDefinitions>

        <Grid Grid.Row="0">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"></ColumnDefinition>
                <ColumnDefinition></ColumnDefinition>
            </Grid.ColumnDefinitions>
            <Label FontSize="Large"
                    Margin="10, 10, 10, 0"
                    FontAttributes="Bold"
                    Text="選擇地點"></Label>
            <HorizontalStackLayout Grid.Column="1"
                                    HorizontalOptions="End">
                <Button Text="刪除"
                        Margin="5,0"
                        Command="{Binding Remove}"></Button>
                <Button Text="完成"
                        Margin="5,0"
                        Command="{Binding Done}"></Button>
            </HorizontalStackLayout>
        </Grid>

        <Grid Grid.Row="1"
                Margin="10, 10, 10, 0">
            <Grid.RowDefinitions>
                <RowDefinition></RowDefinition>
                <RowDefinition Height="Auto"></RowDefinition>
            </Grid.RowDefinitions>
            <Label HorizontalTextAlignment="Center"
                    VerticalOptions="Center"
                    x:Name="ContentLabel"
                    Text="{Binding Address}"></Label>
            <Border IsVisible="False"
                    Grid.RowSpan="2"
                    x:Name="ContentFrame">
                <Entry Text="{Binding Address, Mode=TwoWay}"
                        Placeholder="請輸入地址, 按Enter鍵完成"
                        Completed="Entry_Completed"
                        Unfocused="Entry_Unfocused"
                        ClearButtonVisibility="WhileEditing"></Entry>
            </Border>
            <Border x:Name="ContentButton"
                    Grid.Row="1"
                    HorizontalOptions="Center"
                    VerticalOptions="Center">
                <Label>
                    <Label.FormattedText>
                        <FormattedString>
                            <Span FontFamily="FontAwesome"
                                    Text="&#xf044;"></Span>
                            <Span Text=" 修改"></Span>
                        </FormattedString>
                    </Label.FormattedText>

                </Label>
                <Border.GestureRecognizers>
                    <TapGestureRecognizer Tapped="TapGestureRecognizer_Tapped">
                    </TapGestureRecognizer>
                </Border.GestureRecognizers>
            </Border>
        </Grid>
        <BlazorWebView Grid.Row="2"
                        Margin="-10, 0"
                        x:Name="mainMapBlazorWebView"
                        HostPage="wwwroot/amap_index.html">
            <BlazorWebView.RootComponents>
                <RootComponent Selector="#app"
                                x:Name="rootComponent"
                                ComponentType="{x:Type views:AMapPage}" />
            </BlazorWebView.RootComponents>
        </BlazorWebView>
    </Grid>
</Border>

在這裡插入圖片描述

最終效果如下:

在這裡插入圖片描述

專案地址

Github:maui-samples

相關文章