Xamarin.Forms アプリケーションを .NET MAUI へ手動移行してみる

2022.08.26

いわさです。

Xamarin のサポート終了が 2024 年 5 月 1 日 までというアナウンスが最近ありました。
Android 13 および Xcode 14 SDK (iOS および iPadOS 16, macOS 13) が Xamarin がサポートする最終バージョンとなる予定です。

サポートが終了するわけですが移行パスとして後継の .NET MAUI というものがあります。
DevelopersIO でも何度か紹介させて頂きました。

上記記事では Visual Studio 2022 のプレビュー版でのみ利用が出来ていましたが、本日時点では正式版でも利用が可能になっています。

そのまま .NET MAUI として実行出来るのか?

そのままの実行は出来ません。
ただし、完全に .NET MAUI で作り直しをする必要もありません。

プロジェクトへいくつかの変更を加えることで Xamarin プロジェクトを .NET MAUI へ変換することが出来ます。

移行方法は以下が最新情報として更新され続けています。

また、手動での移行の他に、移行アシスタントツールも用意されています。

移行してみる

本日は上記の手順に従って Xamarin.Forms プロジェクトを .NET MAUI へ手動で移行してみたいと思います。
実際には古い Xamarin.Forms のバージョンを事前に更新したり、プロジェクトの複雑さに応じて様々な手順が追加されるであろうと思っています。
この記事では Xamarin.Forms テンプレートで作成されるデフォルト状態から .NET MAUI への移行を行ってみましょう。

実際にはひとつひとつの変更について、なぜ .NET MAUI でこのようにするべきなのかを理解しながら行う必要があると思います。
この記事ではどういうステップが必要なのかを把握するために一旦動かすことをゴールとしています。

それぞれの変更の掘り下げは別途調べながらまとめていきたいと思います。

Xamarin.Forms プロジェクトの作成・実行

Visual Studio のバージョンは 17.3.2 です。
Visual Studio Installer で標準インストール可能な Xamarin.Forms テンプレートを使って新規作成し、ビルドと実行をしてみましょう。

3 種類からテンプレートが選択出来るようなのですが、今回は以下のようなサイドメニューを持ったテンプレートを使ってみました。
Xamarin.Forms のバージョンは 5.0.0 です。

プロジェクトファイルを修正

Xamarin.Forms 共通のものと、Xamarin.Android、Xamarin.iOS の 3 つのプロジェクトがあり、それぞれにプロジェクトファイルが存在していますが、まずはそれらを変更し .NET SDK & .NET MAUI を対象としたものに変更していきます。

Visual Studio Code を使って修正しています。

HogeXam/HogeXam/HogeXam.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net6.0-ios;net6.0-android</TargetFrameworks>
    <UseMaui>True</UseMaui>
    <OutputType>Library</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <!-- Required for C# Hot Reload -->
    <UseInterpreter Condition="'$(Configuration)' == 'Debug'">True</UseInterpreter>
    <SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net6.0-ios'">15.4</SupportedOSPlatformVersion>
    <SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net6.0-android'">31.0</SupportedOSPlatformVersion>
  </PropertyGroup>
  <ItemGroup>
    <MauiFont Include="Resources\*" />
  </ItemGroup>
</Project>

HogeXam/HogeXam.Android/HogeXam.Android.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <UseMaui>true</UseMaui>
    <TargetFramework>net6.0-android</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net6.0-android'">31.0</SupportedOSPlatformVersion>
    <GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
  </PropertyGroup>
  <PropertyGroup>
    <UseInterpreter Condition="$(TargetFramework.Contains('-android'))">True</UseInterpreter>
  </PropertyGroup>
  <ItemGroup>
    <AndroidResource Include="Resources\values\styles.xml" />
    <AndroidResource Include="Resources\values\colors.xml" />
    <AndroidResource Include="Resources\mipmap-anydpi-v26\icon.xml" />
    <AndroidResource Include="Resources\mipmap-anydpi-v26\icon_round.xml" />
    <AndroidResource Include="Resources\mipmap-hdpi\icon.png" />
    <AndroidResource Include="Resources\mipmap-hdpi\launcher_foreground.png" />
    <AndroidResource Include="Resources\mipmap-mdpi\icon.png" />
    <AndroidResource Include="Resources\mipmap-mdpi\launcher_foreground.png" />
    <AndroidResource Include="Resources\mipmap-xhdpi\icon.png" />
    <AndroidResource Include="Resources\mipmap-xhdpi\launcher_foreground.png" />
    <AndroidResource Include="Resources\mipmap-xxhdpi\icon.png" />
    <AndroidResource Include="Resources\mipmap-xxhdpi\launcher_foreground.png" />
    <AndroidResource Include="Resources\mipmap-xxxhdpi\icon.png" />
    <AndroidResource Include="Resources\mipmap-xxxhdpi\launcher_foreground.png" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\HogeXam\HogeXam.csproj">
      <Project>{611DC49D-0BA5-496A-8DD6-7823B6658E64}</Project>
      <Name>HogeXam</Name>
    </ProjectReference>
  </ItemGroup>
</Project>

HogeXam/HogeXam.iOS/HogeXam.iOS.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <UseMaui>true</UseMaui>
    <TargetFramework>net6.0-ios</TargetFramework>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\HogeXam\HogeXam.csproj">
      <Project>{611DC49D-0BA5-496A-8DD6-7823B6658E64}</Project>
      <Name>HogeXam</Name>
    </ProjectReference>
  </ItemGroup>
</Project>

上記の構成がおそらく最小構成になっています。
Xamarin.Forms から比較すると多くの情報が削減されていますが、ネイティブプロジェクト固有のリソースやファイル無視してこれでいいのか?みたいなところはあります。
本日は一旦このまま進めますが、プロジェクトファイルの構成についてはしっかり抑えておく必要がありそうだなというところです。

あまり今まで意識してなかったのですが、Xamarin.Forms プロジェクトは SDK スタイルで、Xamarin.iOS と Xamarin.Android プロジェクトは非 SDK スタイルだったのだとこの時知りました。

スタートアップ周りのクラスを修正したり追加したり

コードとしていくつか追加や修正すべきものがあります。
主にエントリポイントに関連したいくつかのクラスです。

新規で追加するものあれば、継承元変更した上で不要な初期化処理を削除したりしています。
こちらも追いかけるべきですが今日のところはドキュメントに従って対応のみします。

共通プロジェクトにMauiProgram.csを追加

HogeXam/HogeXam/MauiProgram.cs

namespace HogeXam;

public static class MauiProgram
{
	public static MauiApp CreateMauiApp()
	{
		var builder = MauiApp.CreateBuilder();
		builder
			.UseMauiApp<App>()
			.ConfigureFonts(fonts =>
			{
				fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
			});

		return builder.Build();
	}
}

Android へMainApplication.csを追加

HogeXam/HogeXam.Android/MainApplication.cs

using System;
using Android.App;
using Android.Runtime;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
using HogeXam;

namespace HogeXam.Droid
{
	[Application]
	public class MainApplication : MauiApplication
	{
		public MainApplication(IntPtr handle, JniHandleOwnership ownership)
			: base(handle, ownership)
		{
		}

		protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
	}
}

Android のMainActivity.csを修正

HogeXam/HogeXam.Android/MainActivity.cs

using System;

using Android.App;
using Android.Content.PM;
using Android.Runtime;
using Android.OS;

using Microsoft.Maui;

namespace HogeXam.Droid
{
    [Activity(Label = "HogeXam", Icon = "@mipmap/icon", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize )]
    // public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
    public class MainActivity : MauiAppCompatActivity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            base.OnCreate(savedInstanceState);

            // Xamarin.Essentials.Platform.Init(this, savedInstanceState);
            // global::Xamarin.Forms.Forms.Init(this, savedInstanceState);
            // LoadApplication(new App());
        }
        //public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults)
        //{
        //    Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults);

        //    base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
        //}
    }
}

iOS のAppDelegate.csを修正

HogeXam/HogeXam.iOS/AppDelegate.cs

using System;
using System.Collections.Generic;
using System.Linq;

using Foundation;
using UIKit;

using Microsoft.Maui;
using HogeXam;

namespace HogeXam.iOS
{
    // The UIApplicationDelegate for the application. This class is responsible for launching the 
    // User Interface of the application, as well as listening (and optionally responding) to 
    // application events from iOS.
    [Register("AppDelegate")]
    // public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
    // {
    //     //
    //     // This method is invoked when the application has loaded and is ready to run. In this 
    //     // method you should instantiate the window, load the UI into it and then make the window
    //     // visible.
    //     //
    //     // You have 17 seconds to return from this method, or iOS will terminate your application.
    //     //
    //     public override bool FinishedLaunching(UIApplication app, NSDictionary options)
    //     {
    //         global::Xamarin.Forms.Forms.Init();
    //         LoadApplication(new App());

    //         return base.FinishedLaunching(app, options);
    //     }
    // }
    public partial class AppDelegate : MauiUIApplicationDelegate
    {
        protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
    }
}

全ての AssemblyInfo.cs をコメントアウト

それぞれのプロジェクトにAssemblyInfo.csが含まれていますが全てコメントアウトあるいは削除します。
後述しますが、このドキュメントに記載のない部分で Android 側の Resource.Designer.csでエラーが発生する問題もありました。
自動生成コード周りは少し気をつけたほうが良いかもしれません。

HogeXam/HogeXam.Android/Properties/AssemblyInfo.cs

// using System.Reflection;
// using System.Runtime.CompilerServices;
// using System.Runtime.InteropServices;
// using Android.App;

// // General Information about an assembly is controlled through the following 
// // set of attributes. Change these attribute values to modify the information
// // associated with an assembly.
// [assembly: AssemblyTitle("HogeXam.Android")]
// [assembly: AssemblyDescription("")]
// [assembly: AssemblyConfiguration("")]
// [assembly: AssemblyCompany("")]
// [assembly: AssemblyProduct("HogeXam.Android")]
// [assembly: AssemblyCopyright("Copyright ©  2014")]
// [assembly: AssemblyTrademark("")]
// [assembly: AssemblyCulture("")]
// [assembly: ComVisible(false)]

// // Version information for an assembly consists of the following four values:
// //
// //      Major Version
// //      Minor Version 
// //      Build Number
// //      Revision
// [assembly: AssemblyVersion("1.0.0.0")]
// [assembly: AssemblyFileVersion("1.0.0.0")]

// // Add some common permissions, these can be removed if not needed
// [assembly: UsesPermission(Android.Manifest.Permission.Internet)]
// [assembly: UsesPermission(Android.Manifest.Permission.WriteExternalStorage)]

名前空間を機械的に置換

Xamarin.FormsXamarin.Essentialsを参照している箇所が多いと思いますが、名前空間が変わる形で基本的には多くの API がそのまま利用出来ます。
これは嬉しいですね。
今回は Visual Studio Code の一括置換機能で変更しました。

変更前 変更後
xmlns="http://xamarin.com/schemas/2014/forms" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
using Xamarin.Forms using Microsoft.Maui AND using Microsoft.Maui.Controls
using Xamarin.Forms.Xaml using Microsoft.Maui.Controls.Xaml
using Xamarin.Essentials using Microsoft.Maui.Essentials

途中、using Microsoft.Maui AND using Microsoft.Maui.Controlsってなんやねんって思いましたが、名前空間なので宣言されてる分には問題ないだろうということで、今回はどちらも追加しました。

NuGet パッケージの古いやつを消す

新しいプロジェクトでは .NET MAUI 関係のライブラリを使い、先程置換したXamarin.FormsXamarin.Essentialsが不要になりますので、パッケージマネージャーを使ってアンインストールします。

ビルドしてエラーを潰す

最後に Visual Studio でビルドして発生したエラーを解消していきます。
ここばかりは事前にどの程度問題対処が必要か見積もりにくいところですが、まぁまずはビルドするだけしてみなよという感じでございます。

デフォルトの Xamarin.Forms サイドバーテンプレートの場合は 3 箇所くらい修正が必要な部分がありましたので紹介します。
正直いうと、思ったより少なかったです。

移行前コードに using を省略してクラス利用している部分があった

Shellという仕組みが Xamarin.Forms にあり、.NET MAUI でも利用することが可能です。
こちらは名前空間の変更で Xamarin.Forms.Shell を参照している箇所が Micorsoft.Maui.Controls.Shell に切り替わるはずだったのですが、コード上で名前空間から指定して使っている箇所がありました。
こういった使い方しているところは置換や using への置き換え方法検討するなど注意したほうが良さそうですね。

XAML で clr-namespace を使っているところ

先程の置換作業では、xmlnsを対象として XAML 向けの置換を 1 つ行い、残りは using の置き換えで済む想定の置換になっています。
以下のようにclr-namespaceを XAML 上で指定しているがあるので、この場合の置換方法も考える必要がありますね。

今回は一箇所だったので手動で潰しましたが、iOSSpecificは結構使ってる方いるのでは。

変更前

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="HogeXam.Views.NewItemPage"
             Shell.PresentationMode="ModalAnimated"
             Title="New Item"
             xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
             ios:Page.UseSafeArea="true">
    <ContentPage.Content>
:

変更後

<?xml version="1.0" encoding="UTF-8"?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="HogeXam.Views.NewItemPage"
             Shell.PresentationMode="ModalAnimated"
             Title="New Item"
             xmlns:ios="clr-namespace:Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;assembly=Microsoft.Maui.Controls"
             ios:Page.UseSafeArea="true">
    <ContentPage.Content>
:

Xamarin.Android.Resource.designer.cs をコメントアウト

これは正直 .NET MAUI でどうなるのかを把握せずに使っていますが、Andoird.Resource 配下のResource.designer.csで Xamarin.Forms など旧名前空間を大量に参照していました。
自動生成部分だとするとコメントアウトは悪手だろうなとい思いつつ動かすために今回はこのように対応しました。

リソース周りのプロパティを変更するかプロジェクト側の設定な気はしていますが、このあたりはもう少し調査して正しい対処方法を見つけたいところです。

実行

さて、ここまでの対応でビルドエラーが全て無くなりました。
最後に、UI の文言を少し変更し新しい .NET MAUI アプリケーションとして実行してみましょう。

Android

以下は Android エミュレーターでの実行結果です。
Xamarin.Forms と比較するとツールバー部分がおかしくなってますが、それ以外は問題なく動いていてレイアウトも崩れていません。
ツールバーは、おそらく先程の Resource.Designer.cs の対処方法が適当だったことが原因なんじゃないかなと思ってます。

iOS

続いて iOS シミュレーターでの実行結果です。
こちらは macOS 上で maui インストールしてコマンドから実行しました。

% sudo dotnet workload install maui --source https://api.nuget.org/v3/index.json
Password:

.NET 6.0 へようこそ!
---------------------
SDK バージョン: 6.0.400

テレメトリ
---------
.NET ツールは、エクスペリエンスの向上のために利用状況データを収集します。データは Microsoft によって収集され、コミュニティと共有されます。テレメトリをオプトアウトするには、好みのシェルを使用して、DOTNET_CLI_TELEMETRY_OPTOUT 環境変数を '1' または 'true' に設定できます。

:

Microsoft.Maui.Essentials.Runtime.win バージョン 6.0.486 のワークロード パックのインストール レコードを書き込んでいます...
Pack Microsoft.Maui.Graphics.Win2D.WinUI.Desktop バージョン 6.0.403 をインストールしています...
Microsoft.Maui.Graphics.Win2D.WinUI.Desktop バージョン 6.0.403 のワークロード パックのインストール レコードを書き込んでいます...
SDK 機能バンド 6.0.400 のガベージ コレクトを行っています...

ワークロード maui が正常にインストールされました。

% dotnet restore                                                                
  復元対象のプロジェクトを決定しています...
  /Users/iwasa.takahito/src/sample-xamarin-to-maui-HogeXam/HogeXam/HogeXam.iOS/HogeXam.iOS.csproj を復元しました (1.51 sec)。
  /Users/iwasa.takahito/src/sample-xamarin-to-maui-HogeXam/HogeXam/HogeXam/HogeXam.csproj を復元しました (4.71 sec)。
  /Users/iwasa.takahito/src/sample-xamarin-to-maui-HogeXam/HogeXam/HogeXam.Android/HogeXam.Android.csproj を復元しました (5.52 sec)。

% dotnet build HogeXam/HogeXam.iOS/HogeXam.iOS.csproj -f net6.0-ios -c Debug -t:Run
MSBuild version 17.3.0+92e077650 for .NET
  復元対象のプロジェクトを決定しています...
  復元対象のすべてのプロジェクトは最新です。
  Detected signing identity:
          
    Bundle Id: com.companyname.HogeXam
    App Id: com.companyname.HogeXam
  HogeXam -> /Users/iwasa.takahito/src/sample-xamarin-to-maui-HogeXam/HogeXam/HogeXam/bin/Debug/net6.0-ios/HogeXam.dll
  HogeXam.iOS -> /Users/iwasa.takahito/src/sample-xamarin-to-maui-HogeXam/HogeXam/HogeXam.iOS/bin/Debug/net6.0-ios/iossimulator-x64/HogeXam.iOS.dll
  Optimizing assemblies for size, which may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink

細かいところもう少し評価必要ですが、一旦は .NET MAUI での実行が出来ましたね。

さいごに

本日は Xamarin.Forms アプリケーションを .NET MAUI へ手動移行してみました。

「名前空間とプロジェクトファイル変更すれば概ね動くだろう」と油断していたのですが、まぁまぁ対処すべき点がありますね。
注意が必要です。

一方で構成ファイルや共通部分の修正のみでレイアウトやアプリケーションコードはほとんど修正していません。
Xamarin.Forms を .NET MAUI で最初から作り直しする必要はないということがわかりましたね。

ちなみに、今回修正したコードの全体は以下へアップしております。ご参考までに。

次回以降は昔に作った Xamarin.Forms アプリケーションを変換してみるか、移行アシスタントを使ってみようかなと思っています。
手動だとまずまず労力がいるので、移行アシスタントが充実すると .NET MAUI への移行にあまり抵抗なくなるかもしれませんね。期待したいところです。