Assembly.LoadFile()でdllを読んで、そのdllの中のクラスでキャストをすると、InvalidCastException がでるときがある

もくじ
https://tera1707.com/entry/2022/02/06/144447

やりたいこと

↓に上げた実験コードのように、Winui3のPageFrameを使った画面遷移のコードで、

  • Assembly.LoadFile()で読み込んだdllのなかのクラスのインスタンスを、
  • frameでページを指定して画面遷移するためのcontentFrame.Navigate()の第二引数で渡して、
  • Page側の、そのPageに遷移してきたときの処理のOnNavigatedTo(NavigationEventArgs e)でそれを受けるときに、
  • e.Parameterをそのインスタンスのクラスでキャストすると、下図の例外で落ちる。

ということがあった。

載せた側と受ける側で、同じクラスでキャストしてるはずなのになぜ落ちるのか?が全然わからなかったので、理由を調べたい。

実験① 実際に起きた現象に似せたコードを作ってみる

ClassLibrary1.csprojというdllのプロジェクト

dllに作成したクラス。(中身に特に意味はない)

namespace ClassLibrary1;

public class Class1
{
    public static Class1 CreateInstance() => new Class1(); 

    public int Data1;
    public int Data2;
}

winui3のアプリプロジェクト

メイン画面側で、動的に👆のdllを呼んで、その中のクラスのインスタンスを持つようにした。
(「data 」に入れたものが、「ClassLibrary1.Class1」クラスのインスタンス

で、そのインスタンスを、画面(Page)遷移時のパラメータとして、contentFrame.Navigate()の第二引数に渡した。

using Microsoft.UI.Xaml.Media.Animation;
using System;
using System.Reflection;

namespace FileVerUpTool;

public sealed partial class MainWindow : Window
{
    public MainWindow() => this.InitializeComponent();

    private void nvSample_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
    {
        var dll = Assembly.LoadFile(@"C:\git\FileVerUpTool\ClassLibrary1\bin\x64\Debug\net6.0\ClassLibrary1.dll");
        var type = dll.GetType("ClassLibrary1.Class1");
        var obj = Activator.CreateInstance(type);
        var method = type.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.Public);

        var data = method.Invoke(obj, null);

        if (args.SelectedItemContainer != null)
        {
            if ((string)args.SelectedItemContainer?.Tag == "SamplePage1")
            {
                contentFrame.Navigate(typeof(BlankPage1), (ClassLibrary1.Class1)data, new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromRight });
            }
        }
    }
}

で、ページの中のOnNavigatedTo()で、👆で渡したインスタンスを受けるのだが、その際にClassLibrary1.Class1でキャストして受けると、

using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;

namespace FileVerUpTool;

public sealed partial class BlankPage1 : Page
{
    public BlankPage1() => this.InitializeComponent();

    protected override void OnNavigatedTo(NavigationEventArgs e)
    {
        base.OnNavigatedTo(e);

        var a = (ClassLibrary1.Class1)e.Parameter;

    }
}

上で挙げた例外が起きる。

これが、なんでおきるのかさっぱりわからなかった。

原因

  • `Assembly.LoadFile()で読み込んだ「dll」の中のクラスと、
  • プロジェクトの「参照」に追加して読み込んだ「dll」の中のクラスは、

別物扱いだった。

ここに書かれていた。

stackoverflow.com

最初、NavigationViewの「Navigate()」メソッドのパラメータが怪しい?とか思ってたが、そこは関係なかった。

対策

Assembly.LoadFile()の代わりに、Assembly.LoadFrom()でdllをロードする。

dllのロード動作

Assembly.LoadFile()使用時の、出力欄のモジュール読み込みメッセージはコレ。

1回目も2回目も、両方「シンボルが読み込まれました」となっている。


Assembly.LoadFrom()使用時の、出力欄のモジュール読み込みメッセージはコレ。

どうやら、LoadFrom()のときは、同じdllを2回目によんだときに、アンロードしている様子。
(LoadFileのときは、アンロードは行われず、「シンボルが読み込まれました」となっている)

VisualStudioで「参照」に入れるDLLを、動的にロードしないといけないようなときは、「Assembly.LoadFile()」ではなく「Assembly.LoadFrom()」使えばよいっぽい。

※ただ、そういうことにならないように、どちらか一方(参照に入れるor動的にロードする)だけにした方が自然なのだろうなとは思う。

実験② LoadFileとLoadFromの実験

全く同じDLLを、別のフォルダに置いてみて、その2つをLoadFile、LoadFromをしてみた。

using System.Reflection;

namespace ConsoleApp12;

internal class Program
{
    private static string path1 = @"C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll";
    private static string path2 = @"C:\Users\masa\source\repos\ConsoleApp12\dll2\ClassLibrary1.dll";

    static void Main(string[] args)
    {
        Assembly assembly1 = Assembly.LoadFrom(path1);
        Assembly assembly2 = Assembly.LoadFrom(path2);

        assembly1 = Assembly.LoadFile(path1);
        assembly2 = Assembly.LoadFile(path2);
    }
}

出力

0
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.8\System.Collections.Concurrent.dll' が読み込まれました。シンボルの読み込みをスキップしました。モジュールは最適化されていて、デバッグ オプションの [マイ コードのみ] 設定が有効になっています。
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll' が読み込まれました。シンボルが読み込まれました。
1
2
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll' が読み込まれました。シンボルが読み込まれました。
3
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll2\ClassLibrary1.dll' が読み込まれました。シンボルが読み込まれました。

結果、

  • LoadFileは、別の同じ名前のファイルを、それぞれ別のAssemblyとして読み込んだ。
  • LoadFromは、別の同じ名前のファイルを、1個目のみ読み込んで、2個目は1個目と同じdllを使ったっぽい。

実験③ LoadFileとLoadFromの実験(全く同じdllの一方を無理やりリネームしてみる)

実験②のpath2のほうのdllを無理やり「ClassLibrary1aaa.dll」にリネームして、コード中のpathもそれに合わせて変えてやって、動的に読み込んでみる。

using System.Diagnostics;
using System.Reflection;

namespace ConsoleApp12;

internal class Program
{
    private static string path1 = @"C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll";
    private static string path2 = @"C:\Users\masa\source\repos\ConsoleApp12\dll2\ClassLibrary1aaa.dll";

    static void Main(string[] args)
    {
        int ctr = 0;
        Debug.WriteLine($"{ctr++}");
        var assembly1 = Assembly.LoadFrom(path1);

        Debug.WriteLine($"{ctr++}");
        var assembly2 = Assembly.LoadFrom(path2);

        // -----------------------------

        Debug.WriteLine($"{ctr++}");
        var assembly11 = Assembly.LoadFile(path1);

        Debug.WriteLine($"{ctr++}");
        var assembly12 = Assembly.LoadFile(path2);

    }
}

出力

0
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\PrivateAssemblies\Runtime\Microsoft.VisualStudio.Debugger.Runtime.NetCoreApp.dll' が読み込まれました。シンボルの読み込みをスキップしました。モジュールは最適化されていて、デバッグ オプションの [マイ コードのみ] 設定が有効になっています。
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.8\netstandard.dll' が読み込まれました。シンボルの読み込みをスキップしました。モジュールは最適化されていて、デバッグ オプションの [マイ コードのみ] 設定が有効になっています。
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.8\System.Collections.Concurrent.dll' が読み込まれました。シンボルの読み込みをスキップしました。モジュールは最適化されていて、デバッグ オプションの [マイ コードのみ] 設定が有効になっています。
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll' が読み込まれました。シンボルが読み込まれました。
1
2
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll1\ClassLibrary1.dll' が読み込まれました。シンボルが読み込まれました。
3
'ConsoleApp12.exe' (CoreCLR: clrhost): 'C:\Users\masa\source\repos\ConsoleApp12\dll2\ClassLibrary1aaa.dll' が読み込まれました。シンボルが読み込まれました。

結果、

  • ファイル名が別であっても、中身が同じであれば、LoadFromは同じdllとして、2つ目のLoadFromでは、読み込みを行わなかった。

参考

アセンブリの読み込みのベスト プラクティス
1 つのアセンブリを複数のコンテキストに読み込まない
Assembly.LoadFile()は、別のpathに置かれた同じdllを読むけど、
Assembly.LoadFrom()は、それを読まない、と書いてるっぽい。

https://learn.microsoft.com/ja-jp/dotnet/framework/deployment/best-practices-for-assembly-loading#avoid-loading-an-assembly-into-multiple-contexts

Difference between LoadFile and LoadFrom with .NET Assemblies?
👆のMSdocsが何かいてるかわからん!という記事。同意...

https://stackoverflow.com/questions/1477843/difference-between-loadfile-and-loadfrom-with-net-assemblies