既存のC++(OpenGL)ライブラリ資産を捨てずに.NET MAUIへ移行する近道は、「C++を呼ぶ仕組み」と「描画面(OpenGLコンテキスト/サーフェス)を渡す仕組み」を切り分けることです。本記事ではP/Invokeを軸に、MAUI+Visual Studio 2022(Windows)を前提にした実装例・配置方法・NuGet化・落とし穴までまとめます。
.NET MAUI × C++ 連携は「2つの問題」を分けると一気に楽になる
MFCからMAUIへ移行するときに詰まりやすいのは、C++ライブラリが「計算/モデル処理」だけでなく「OpenGLで描画」まで抱えているケースです。MAUI側でやるべきことは、次の2点を別々に設計することです。
| 分けて考える項目 | 目的 | MAUI側の主な責務 | よくある失敗 |
|---|---|---|---|
| C++を呼ぶ(API呼び出し) | 既存ライブラリの機能を再利用 | P/Invokeで関数を呼ぶ、型/メモリを正しく受け渡す | DllNotFound/EntryPointNotFound、文字コード・呼び出し規約の不一致 |
| 描画面を渡す(OpenGL描画) | MAUI画面内に3D表示 | プラットフォームごとの「描画できるネイティブView」を用意 | MAUIのViewをそのままHWND/Surfaceとして扱おうとして詰む、スレッド事故 |
結論:MAUIからC/C++を呼ぶ基本はP/Invoke(DllImport/LibraryImport)
.NET MAUIでも、従来の.NETと同じくP/Invokeでネイティブ関数を呼び出せます。P/Invokeは「マネージコードからアンマネージライブラリの関数・構造体・コールバックへアクセスする技術」で、主にSystem.Runtime.InteropServicesを使って定義します。最近の.NETではLibraryImport(ソース生成)を使った定義例も公式に示されています。
さらに重要なのが「ライブラリ名の解決」です。[DllImport("mylib")]のように拡張子なしで書いても、ランタイムがWindowsなら.dll、Unix系なら.so/.dylibの付与や、Unix系でのlibプレフィックス付与などを試行して探索します。これを理解しておくと、クロスプラットフォームでの命名・配置が揃えやすくなります。
最小のC#側呼び出し例(まずは動かす)
最初は「数値を返すだけ」の関数で疎通確認するのがおすすめです。OpenGLや大きな構造体の受け渡しは、疎通後に段階的に増やします。
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
// .NET 7+ 推奨:LibraryImport(P/Invokeソース生成)
[LibraryImport("mylib", EntryPoint = "mylib_get_version")]
internal static partial int GetVersion();
}
// 使い方
// int v = NativeMethods.GetVersion();
Visual StudioがDllImportからLibraryImportへの移行(SYSLIB1054)を促すことがあります。これはP/Invokeのマーシャリングコードをコンパイル時に生成できるためで、.NET 7以降の推奨として整理されています。
連携方式の選択肢(P/Invoke以外もある)
「MAUIからC++を使う」と言っても、用途や既存資産の形によって最適解が変わります。特にOpenGL系は、UIにどう埋め込むかで方式が変わりがちです。
| 方式 | クロスプラットフォーム | 実装難度 | 向いているケース | 注意点 |
|---|---|---|---|---|
| P/Invoke(C APIを呼ぶ) | ◎ | 中 | 既存C++を「C関数」で呼べる形にできる/性能重視 | マーシャリング・メモリ管理・呼び出し規約の設計が重要 |
| C++/CLIラッパー | △(Windowsのみ) | 中〜高 | C++クラスをそのまま握りたい(Windows専用でよい) | MAUIのWindows以外に展開できない |
| 別プロセス(gRPC/IPC) | ◎ | 高 | ライブラリが巨大でロードが難しい/クラッシュ隔離したい | 描画用途はレイテンシが課題(ストリーミング設計が必要) |
| Native Library Interop(Slim Binding) | ○ | 中 | Java/Swift等のネイティブSDKを必要なAPIだけ薄く束ねたい | 「C++ DLLを直接呼ぶ」よりは用途が別。MAUI向けの代替アプローチとして理解しておくと有用 |
なお、MAUIの「Native Library Interop(Slim Binding)」は、.NET MAUI(Android/iOS/Mac Catalyst等)でネイティブライブラリAPIへ直接アクセスするための代替アプローチとして紹介されています。C/C++のP/Invokeが主戦場でも、既存SDKの取り込みが発生するプロジェクトでは知っておくと役立ちます。
ステップ:C++側を「C API」に薄くラップする(成功率が一気に上がる)
MFC時代のC++ライブラリが「クラス中心」になっているほど、C#側で直接扱おうとして苦労します。おすすめは、C++内部はそのままに、外部に公開する面だけをC関数に揃えることです。
設計のコツ:ハンドル(IntPtr)でC++オブジェクトを表現する
- C++側は
void*(または不透明ポインタ)を返す - C#側は
IntPtrで受ける - 生成/破棄(Create/Destroy)を必ず対で用意する
- 例外はC境界を越えさせない(エラーコードやログに落とす)
C++側サンプル(エクスポートとextern “C”)
// mylib_api.h
#pragma once
#if defined(_WIN32)
#define MYLIB_API extern "C" __declspec(dllexport)
#define MYLIB_CALL __cdecl
#else
#define MYLIB_API extern "C" **attribute**((visibility("default")))
#define MYLIB_CALL
#endif
typedef void* MYLIB_HANDLE;
MYLIB_API int MYLIB_CALL mylib_get_version();
MYLIB_API MYLIB_HANDLE MYLIB_CALL mylib_create();
MYLIB_API void MYLIB_CALL mylib_destroy(MYLIB_HANDLE h);
// 例:モデル読み込み(UTF-8前提)
MYLIB_API int MYLIB_CALL mylib_load_model(MYLIB_HANDLE h, const char* path_utf8);
// 例:描画(OpenGLコンテキストは呼び出し側で「すでに有効」前提にするのが管理しやすい)
MYLIB_API void MYLIB_CALL mylib_render(MYLIB_HANDLE h);
ポイントは、C#から呼ぶ関数を「Cの関数」としてエクスポートすることです。C++の名前修飾(name mangling)を避け、P/InvokeのEntryPointが安定します。
ステップ:C#側のP/Invoke定義(LibraryImport/DllImportの実務テンプレ)
P/Invokeは「宣言が正しいか」が全てです。特にOpenGL系ライブラリは、初期化/破棄、スレッド、文字列、配列、コールバックが絡みやすいので、最初に型設計を決めておくと後が楽になります。
基本形(IntPtrでハンドルを受ける)
using System;
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
[LibraryImport("mylib", EntryPoint = "mylib_get_version")]
internal static partial int GetVersion();
[LibraryImport("mylib", EntryPoint = "mylib_create")]
internal static partial IntPtr Create();
[LibraryImport("mylib", EntryPoint = "mylib_destroy")]
internal static partial void Destroy(IntPtr handle);
// UTF-8のパスを渡す例(.NET 7+)
[LibraryImport("mylib", EntryPoint = "mylib_load_model", StringMarshalling = StringMarshalling.Utf8)]
internal static partial int LoadModel(IntPtr handle, string path);
}
.NETのP/InvokeはLibraryImportで「部分メソッド+ソース生成」というスタイルが公式に示されています。StringMarshallingなどを明示しておくと、環境差・既定値差での事故が減ります。
安全なリソース管理:SafeHandleで寿命を握る
ネイティブ側のリソース(コンテキスト、モデル、バッファ)をIntPtrのまま運用すると、破棄漏れや二重解放で事故りやすくなります。Interopのベストプラクティスとしても、アンマネージリソースの寿命管理にはSafeHandleを使うことが推奨されています。
using System;
using System.Runtime.InteropServices;
internal sealed class MyLibHandle : SafeHandle
{
private MyLibHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.Destroy(handle);
return true;
}
}
internal static partial class NativeMethods
{
[LibraryImport("mylib", EntryPoint = "mylib_create")]
internal static partial MyLibHandle Create();
[LibraryImport("mylib", EntryPoint = "mylib_destroy")]
internal static partial void Destroy(IntPtr handle);
}
よく使うマーシャリングの型対応表(実務で迷うところだけ)
| ネイティブ側 | C#側 | 備考 |
|---|---|---|
int, uint32_t | int, uint | サイズを揃える(符号も) |
void*(ハンドル) | IntPtr / SafeHandle | 寿命管理はSafeHandle推奨 |
const char*(UTF-8) | string+StringMarshalling.Utf8 | 既定値に依存しない設計が安全 |
出力バッファ(char*等) | byte[]/Span<byte>+サイズ指定 | 文字列を返すより「呼び出し側がバッファを渡す」方が安定 |
| 構造体 | [StructLayout(LayoutKind.Sequential)] | パディング/アラインメントの差に注意 |
文字コードやCharSetの既定値は「WindowsとUnix系で感覚が違う」ため、明示しておくのが安全です(特に文字列を跨ぐAPI)。
ステップ:ネイティブバイナリの配置(Windows/Android/iOSでやることが違う)
P/Invoke自体は同じでも、ライブラリをどうビルドして、どこに置くかはプラットフォームごとに流儀があります。まずは全体像を押さえます。
| プラットフォーム | 成果物 | 典型的な命名 | MAUIでの配置アプローチ |
|---|---|---|---|
| Windows | .dll | mylib.dll | 出力フォルダ/パッケージに同梱(MSIX含む) |
| Android | .so | libmylib.so | AndroidNativeLibraryとしてABI別に追加 |
| iOS / Mac Catalyst / macOS | .a / .framework / .xcframework / .dylib | プロジェクト構成次第 | NativeReferenceやリンカ引数で取り込み |
Windows(Visual Studio 2022 for Windows):まずはdllを確実に同梱する
Windowsはシンプルで、アプリの実行フォルダ(またはパッケージ)にdllが存在すれば読み込めるケースが多いです。MAUIのWindowsはWinUIベースなので、発行形態(MSIX/未パッケージ)によって「コピー先」が変わることがあります。最初は「ビルド出力に確実に出す」を優先し、疎通後にNuGet化・MSIX最適化へ進めると安全です。
<ItemGroup>
<None Include="NativeBinaries\win-x64\mylib.dll"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
Android:AndroidNativeLibraryでABI(arm64-v8a等)を指定する
Androidは端末/エミュレータのCPUアーキテクチャが複数あるため、「この.soはどのABI向けか」をビルドシステムに伝える必要があります。.NET for Androidでは、ネイティブライブラリをAndroidNativeLibraryとして追加し、パスからABIを推測するか、<Abi>メタデータで明示します。
<ItemGroup>
<AndroidNativeLibrary Include="NativeBinaries\android\arm64-v8a\libmylib.so">
<Abi>arm64-v8a</Abi>
</AndroidNativeLibrary>
<AndroidNativeLibrary Include="NativeBinaries\android\armeabi-v7a\libmylib.so">
<Abi>armeabi-v7a</Abi>
</AndroidNativeLibrary>
</ItemGroup>
iOS/Mac Catalyst/macOS:NativeReferenceやリンカ引数で取り込む
.NET for iOS系では、ネイティブ参照を追加するためのNativeReferenceが用意されています。
<ItemGroup>
<NativeReference Include="NativeBinaries\ios\libmylib.a" />
</ItemGroup>
また、必要に応じてリンカ引数を追加して静的ライブラリをリンクする例もドキュメントに示されています。
<ItemGroup>
<LinkerArgument Include="-L$(ProjectDir)NativeBinaries/ios" />
<LinkerArgument Include="-lmylib" />
</ItemGroup>
実務上は、NativeReferenceにKindやSmartLink等のメタデータを持たせて取り込むケース(framework/xcframework)もあります。ドキュメント上も、特定のメタデータがネイティブ参照作成時に利用される旨が記載されています。
NuGet化:Xamarin.Forms用パッケージがMAUIで動かない「典型パターン」と対処
Xamarin.Forms時代に作ったNuGetがMAUIで動かない理由は、だいたい次のどれかです。
- ターゲットフレームワーク(TFM)が合っていない(MAUIは
net8.0-android等の新しいTFMで動く) - ネイティブバイナリの配置規約が合っていない(runtimes/RID/nativeに置けていない)
- ビルド時にコピーされる想定の.props/.targetsがXamarin前提のまま
.NET/MAUI時代の「ネイティブ同梱NuGet」は、基本的にruntimes/{rid}/native/配下へネイティブ資産を置くのが王道です。NuGetはRID(Runtime Identifier)とTFMに基づいて最適な資産を選択し、ネイティブ資産はruntimes/{rid}/native/から出力へコピーされます。
NuGetパッケージ構成のイメージ
MyLib.Interop.nupkg
ref/net8.0/MyLib.Interop.dll
runtimes/win-x64/native/mylib.dll
runtimes/win-arm64/native/mylib.dll
runtimes/android-arm64/native/libmylib.so
runtimes/android-arm/native/libmylib.so
runtimes/osx-arm64/native/libmylib.dylib
...
RIDの名前(win-x64、android-arm64等)は.NETのRIDカタログに従います。適当な文字列を作ると復元や実行時解決で詰むので、既存のRIDを使うのが安全です。
.csprojでPackするときの最小例(ネイティブをruntimesへ)
MSBuildのPackで任意ファイルを任意パスに入れられることが、公式ドキュメントで例示されています。これを使うと、NuGetの規約に沿った配置が作れます。
<ItemGroup>
<None Include="NativeBinaries\win-x64\mylib.dll"
Pack="true"
PackagePath="runtimes/win-x64/native/" />
<None Include="NativeBinaries\android-arm64\libmylib.so"
Pack="true"
PackagePath="runtimes/android-arm64/native/" />
</ItemGroup>
補足として、RID指定なしでpublishする場合、RID別資産はサブディレクトリに置かれ、ランタイムはdeps.jsonを見て適切な探索パスを決めます(この仕組みを前提にruntimes配下へ入れるのが楽です)。また、runtimes/{rid}/native/配下のディレクトリ構造が出力時にフラット化される点も把握しておくと「想定の場所に無い」系の混乱が減ります。
MAUI側の実装:UIから直接NativeMethodsを叩かない
動くサンプルを作るだけなら、ViewModelからP/Invokeを直呼びしても動きます。しかし運用を考えると、次の理由で「サービス層」で包むのが得です。
- 例外の境界(DllNotFoundなど)を1箇所で吸収できる
- プラットフォーム差(Windowsはdll、iOSは静的リンク等)を隠蔽できる
- OpenGLのコンテキスト管理やスレッド制約を集約できる
インターフェース+DI(MAUIの定番)
public interface IModelRenderer : IDisposable
{
int Version { get; }
void Load(string path);
void RenderFrame();
}
public sealed class MyLibRenderer : IModelRenderer
{
private readonly MyLibHandle _h = NativeMethods.Create();
public int Version => NativeMethods.GetVersion();
public void Load(string path)
{
int rc = NativeMethods.LoadModel(_h, path);
if (rc != 0) throw new InvalidOperationException($"LoadModel failed: {rc}");
}
public void RenderFrame()
{
NativeMethods.Render(_h);
}
public void Dispose() => _h.Dispose();
}
OpenGL描画をMAUIに載せる実践パターン(ここが本番)
「C++を呼べた」だけでは、画面に3Dが出ません。MFCではViewがそのままOpenGLの描画先になりましたが、MAUIではプラットフォームごとにUIが違い、OpenGLコンテキストの作り方・紐付け方も違います。実務では次の3パターンのどれかに落とします。
ネイティブView上でOpenGLを回す(推奨)
- Windows:HWND/SwapChain/DirectX連携など、Windows側の描画面を確保してコンテキストを作る
- Android:
GLSurfaceViewやSurfaceViewでEGLコンテキストを作る - iOS:
UIView+(OpenGL ESの場合)EAGL/CAEAGLLayer等、またはMetal系へ(将来性)
MAUIでは「カスタムコントロール+Handler」でプラットフォームごとにネイティブViewを生成し、そのネイティブViewに対してC++レンダラを初期化する、という設計が最も破綻しにくいです。UI側はあくまで「ネイティブの描画面をホストする箱」として扱い、描画ループ・コンテキスト寿命はネイティブ側(またはサービス層)に寄せます。
オフスクリーン描画→画像としてMAUIに渡す(安全だが重い)
- C++側でFBO等に描画し、RGBAバッファを取り出す
- C#側で
ImageSource(またはSkiaSharp等)に変換して表示 - UI統合は簡単だが、毎フレーム転送すると帯域/CPU負荷が大きい
ライブラリ側で別ウィンドウを持つ(最短だがUXは分離)
- 既存のMFC資産やWin32ウィンドウ生成コードが強い場合に短期で動かせる
- MAUI画面内に埋め込みたい場合は結局ホスティングが課題になる
どの方式でも、最初のゴールは「小さな三角形が回る」程度の描画疎通にしておくと、UI統合や入力イベント(マウス/タッチ)を後から足しやすいです。
よくある例外と切り分け(これだけで詰まりが半分減る)
| 症状 | 代表原因 | 最短の確認/対処 |
|---|---|---|
DllNotFoundException | dll/soが見つからない、依存dllが不足、配置が違う | 出力フォルダ(Windows)/APK内(Android)に本体と依存が入っているか確認。NuGetならruntimes/RID/nativeに入っているか確認 |
EntryPointNotFoundException | 関数名が一致しない(C++の修飾、EntryPoint違い) | C++側をextern "C"でエクスポート、C#側のEntryPointを明示 |
BadImageFormatException | x86/x64/arm64不一致 | Windowsならアプリのアーキテクチャとdllのアーキを一致。Android/iOSもABI別ビルドが必要 |
| 文字化け/クラッシュ | 文字コード・CharSet・バッファサイズ不一致 | UTF-8/UTF-16の方針を固定し、StringMarshallingやCharSetを明示。出力文字列は「呼び出し側バッファ渡し」へ |
ライブラリ名の付け方については、ランタイムが拡張子付与やlibプレフィックスを自動で試行するため、クロスプラットフォームでは「拡張子なし+同一ベース名」に揃えると管理が楽です。
どうしても解決できないとき:DllImportResolverで読み込みを制御する
「配置は合っているはずなのに読み込めない」「CPU機能や配布形態で読み込むdllを切り替えたい」といったケースでは、NativeLibrary.SetDllImportResolverでアセンブリ単位の解決ロジックを登録できます。これはアセンブリに対してネイティブライブラリ解決のコールバックを設定するAPIとして定義されています。
using System.Reflection;
using System.Runtime.InteropServices;
public static class NativeLoader
{
public static void Register()
{
NativeLibrary.SetDllImportResolver(
Assembly.GetExecutingAssembly(),
(name, assembly, path) =>
{
if (name == "mylib")
{
// 必要なら独自のロード先を指定(例:サブフォルダ)
// return NativeLibrary.Load("NativeBinaries/win-x64/mylib.dll", assembly, path);
}
return IntPtr.Zero; // 既定の探索へフォールバック
});
}
}
移行チェックリスト(MFC→MAUIで失敗しないための要点)
- まずは「戻り値intだけ」の関数でP/Invoke疎通を取った
- C++公開面は
extern "C"+C関数へ寄せた(クラス直叩きを避けた) - ハンドル型(
void*/IntPtr)+Create/Destroyを必ず用意した - 文字列はUTF-8 or UTF-16のどちらかに統一し、C#側で明示した
- Windows/Android/iOSで「成果物の種類」と「配置方法」が違うことを前提にした
- AndroidはABI別に.soを用意し、
AndroidNativeLibraryでABIを指定した - iOSは
NativeReferenceやリンカ設定を確認し、静的リンクの前提を崩さない設計にした - OpenGLは「描画面の提供」が別問題だと割り切り、ネイティブViewホスト方式を検討した
- ネイティブ資産の配布はNuGetの
runtimes/{rid}/native規約を優先した - 実行時の読み込み問題に備えて、必要ならResolverで制御できる設計にした
まとめ:MAUIでもC++資産は活かせる。鍵は「薄いC API」と「正しい配置」
.NET MAUI+Visual Studio 2022(Windows)でも、C++ライブラリを呼び出す基本はP/Invokeです。MFCからの移行で重要なのは、UIフレームワークを置き換えてもネイティブ資産を最大限再利用できるように、境界面(C API)と配置/パッケージング(RID/ABI)を先に固めることです。OpenGL表示まで含める場合は、P/Invoke疎通の後に「描画面をどう用意するか」をプラットフォーム別に詰めると、遠回りせずに完成へ持っていけます。

コメント