.NET MAUIでC++ライブラリを呼び出す方法:P/Invoke(DllImport/LibraryImport)とNuGet配布、OpenGL連携の実践手順

既存の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_tint, 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.dllmylib.dll出力フォルダ/パッケージに同梱(MSIX含む)
Android.solibmylib.soAndroidNativeLibraryとして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統合や入力イベント(マウス/タッチ)を後から足しやすいです。

よくある例外と切り分け(これだけで詰まりが半分減る)

症状代表原因最短の確認/対処
DllNotFoundExceptiondll/soが見つからない、依存dllが不足、配置が違う出力フォルダ(Windows)/APK内(Android)に本体と依存が入っているか確認。NuGetならruntimes/RID/nativeに入っているか確認
EntryPointNotFoundException関数名が一致しない(C++の修飾、EntryPoint違い)C++側をextern "C"でエクスポート、C#側のEntryPointを明示
BadImageFormatExceptionx86/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疎通の後に「描画面をどう用意するか」をプラットフォーム別に詰めると、遠回りせずに完成へ持っていけます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次