如何使附属程序集与 PublishSingleFile 中的 WPF 应用程序一起工作

How do i make satellite assemblies work with my WPF app in PublishSingleFile

我有一个现有的本地化 WPF 应用程序,我的本地化存储在一堆 .resx 文件中,并通过默认 resx 自定义工具生成的“.Designer.cs”文件访问。每种受支持的语言都有其自己的每个 .resx 文件版本。它工作得很好,但每次我们想要调整翻译时我都必须重新编译应用程序,一旦应用程序已交付给多个客户,这不是最实际的事情。

我的应用程序以 PublishSingleFile 模式发布,我的设置添加了一些配置文件。用户应该在某个时候访问配置文件,所以我想保持该目录尽可能干净。

似乎 .NET 方法是通过附属程序集来实现这一点,但它们与已发布的应用程序和 PublishSingleFile 选项的交互没有很好的记录。

怎么办?

我做了一个 test project on github 来尝试解决这个问题。基础项目有一个标签,此答案(的原始版本)中描述的步骤有不同的标签。 None 这太复杂了,但是为了使一切正常,有很多步骤。 此答案中描述的步骤基于该项目

这是一个非常基本的 WPF 应用程序,有 1 个 windows 和几个控件、2 个资源文件 Resources.resxErrors.resx,在 Properties 子文件夹中,以及它们的翻译在法语和德语中放入 .{culture}.resx 个文件(总共 6 个文件)。有一个按钮可以将 UI 从英语切换到法语,然后从德语切换到法语,然后从德语切换回英语。

在开始解释如何操作之前,需要考虑以下几点:

  • 我们将使用 2 个程序,它们是 .NET SDK 的一部分:resgen.exeal.exe。 AFAIK,使用的版本并不重要,我想我能够在某些时候使用这些文件的 .net Framework 2 版本。
  • 这些文件在您系统上的位置可能不同。我使用了 C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ 中的那些
    • resgen.exe
    • x64\al.exe <- 如果在 x64 中编译,请确保使用 x64 版本
  • 我们使用 PublishSingleFile 来避免在我们的应用程序文件夹中出现大量混乱,因此如果应用程序以 20 种语言本地化,我们希望避免其中有 20 个文件夹。
  • 为了查看哪些资源嵌入在哪些程序集中,检查程序集可能很有​​用,例如使用 ILSpy。

让我们一步一步来。

第 1 步:使用 VS 创建附属程序集

  1. 在保持翻译数据不变的同时,删除处理 .resx 文件的默认配置
    • 将所有 resx 文件的属性设置为 None/Do not copy
    • Resources.resxErrors.resx
    • 中删除自定义工具
    • 删除Errors.Designer.csResources.Designer.cs
  2. 从您的默认语言 .resx 生成 .resources 文件。
    • 将 pre-build 事件设置为(您的 resgen 命令的路径可能不同):
      set resgen = "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
      resgen Properties\Resources.resx /str:cs,$(ProjectName).Properties,Resources /publicClass
      resgen Properties\Errors.resx /str:cs,$(ProjectName).Properties,Errors /publicClass
      
    • 尝试构建应用程序。它将在您的 Properties 文件夹中创建文件 Resources.resourcesErrors.resources。忽略警告,生成一切正常。
    • Resources.resourcesErrors.resources 的属性设置为 Embedded Resource/Do not copy
    • 重建应用程序。该程序找不到法语和德语翻译,但已将英语默认值嵌入其中。
  3. Visual Studio 生成附属程序集
    • 在pre-build事件中,添加以下行

      echo "fr-FR"
      %resgen% Properties\Errors.fr-FR.resx
      %resgen% Properties\Resources.fr-FR.resx
      
      echo "de-DE"
      %resgen% Properties\Errors.de-DE.resx
      %resgen% Properties\Resources.de-DE.resx
      
      echo "en-US"
      echo F|xcopy Properties\Errors.resources Properties\Errors.en-US.resources /Y
      echo F|xcopy Properties\Resources.resources Properties\Resources.en-US.resources /Y
      
    • 构建一次。 pre-build 事件将为所有 3 种语言的两个文件生成 .resources 文件。

    • 将所有 .resources 文件的属性设置为 Embedded Resource/Do not copy

    • 再次构建,visual studio 现在将为所有 3 种语言生成附属程序集。

说明

“中性”.resources 文件嵌入到应用程序的 dll 中。如果未找到附属程序集,将根据该文件翻译文本。为了修改默认翻译,我们必须通过重建整个应用程序来重新编译应用程序的 dll。但是,cutlure-specific 翻译已嵌入到卫星程序集中,可以单独编译和发布,而无需接触应用程序。

pre-build 事件执行以下操作:

  • 为中性文化生成 .resources 文件,同时自动创建一个 .cs 文件,将每个资源字符串映射到静态 属性 以便于使用(“强类型资源”,就像默认自定义工具为 .resx 文件自动创建的 .Designer.cs 文件一样)。
  • 为法国和德国文化生成 .resources 文件。
  • 将 culture-neutral .resources 文件复制成英文 .resources 文件。
  • 通过将这些设置为嵌入式资源,visual studio 将自动:
    • 将 culture-neutral 资源嵌入到应用程序的 dll 中,这样即使无法找到附属程序集,也始终会翻译文本。
    • 为找到的每种文化创建附属程序集,并将特定于该文化的 .resources 文件嵌入该程序集。
    • 因此,对于 en-US、fr-FR 和 de-DE 文化,我们最终得到 3 个附属程序集。

测试附属程序集

应用程序有一个切换文化的按钮。要测试附属程序集是否正常工作,您可以简单地删除一种文化,比如 de-DE,并检查它是否翻译成法语,但在选择德语时恢复为中性(英语)。

一种更彻底的测试方法是生成新的附属程序集。你可以为此制作一个脚本。

  1. 构建应用程序
  2. 直接在您的 .resx 文件中修改翻译。请勿再次构建。
  3. 制作一个脚本(在github项目中,它被称为updateDll.bat)来生成附属程序集。以下假设我们正在 Debug|x64 中构建和测试。
    set resgen="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
    set al="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\al.exe"
    %resgen% Properties\Resources.resx
    %resgen% Properties\Errors.resx
    %resgen% Properties\Resources.fr-FR.resx
    %resgen% Properties\Errors.fr-FR.resx
    %resgen% Properties\Resources.de-DE.resx
    %resgen% Properties\Errors.de-DE.resx
    
    %al% -target:lib -embed:Properties\Resources.resources,SatelliteLocDemo.Properties.Resources.en-US.resources -embed:Properties\Errors.resources,SatelliteLocDemo.Properties.Errors.en-US.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:en-US -out:bin\x64\Debug\en-US\SatelliteLocDemo.resources.dll 
    
    %al% -target:lib -embed:Properties\Resources.fr-FR.resources,SatelliteLocDemo.Properties.Resources.fr-FR.resources -embed:Properties\Errors.fr-FR.resources,SatelliteLocDemo.Properties.Errors.fr-FR.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:fr-FR -out:bin\x64\Debug\fr-FR\SatelliteLocDemo.resources.dll 
    
    %al% -target:lib -embed:Properties\Resources.de-DE.resources,SatelliteLocDemo.Properties.Resources.de-DE.resources -embed:Properties\Errors.de-DE.resources,SatelliteLocDemo.Properties.Errors.de-DE.resources -template:bin\x64\Debug\SatelliteLocDemo.dll -culture:de-DE -out:bin\x64\Debug\de-DE\SatelliteLocDemo.resources.dll 
    
  4. 运行 脚本,然后从资源管理器导航到您的构建文件夹和 运行 您的应用程序(如果您从 VS 运行,它将首先重建整个应用程序)。

第 2 步:手动创建附属程序集并清理程序目录

如果希望用户与该文件夹进行交互(例如,用于编辑配置文件),则在您的应用程序旁边为每种语言设置一个文件夹可能看起来很糟糕。我们会将所有翻译放在一个 Languages 目录中,以保持内容整洁。

  1. 不要让 Visual Studio 生成附属程序集

    • 从解决方案中删除所有 6 个 .culture.resources 文件(保留中性文件 Resources.resourcesErrors.resources,以便应用程序的程序集与默认翻译捆绑在一起)。
    • 避免在 pre-build 事件中创建 culture-specific .resources 文件。仅保留 culture-neutral 个(删除除前 3 行以外的所有内容)。
    • 在 post-build 事件中手动生成附属程序集,类似于我们在 updateDll.bat 中的做法。 .culture.resources 文件将生成到 obj\ 文件夹中。我们不需要英语语言,en-US 附属程序集将直接从中性 .resources 文件生成。
      set resgen="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ResGen.exe"
      set al="C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\al.exe"
      
      echo "Compile resx"
      SET resourcesPath="obj$(PlatformName)$(ConfigurationName)\Properties"
      if not exist %resourcesPath% mkdir %resourcesPath%
      %resgen% Properties\Resources.fr-FR.resx %resourcesPath%\Resources.fr-FR.resources
      %resgen% Properties\Resources.de-DE.resx %resourcesPath%\Resources.de-DE.resources
      %resgen% Properties\Errors.fr-FR.resx %resourcesPath%\Errors.fr-FR.resources
      %resgen% Properties\Errors.de-DE.resx %resourcesPath%\Errors.de-DE.resources
      
      echo "en-US"
      SET enusPath="$(TargetDir)\Languages\en-US"
      if not exist %enusPath% mkdir %enusPath%
      %al% -target:lib -embed:Properties\Resources.resources,$(ProjectName).Properties.Resources.en-US.resources -embed:Properties\Errors.resources,$(ProjectName).Properties.Errors.en-US.resources -template:$(TargetPath) -culture:en-US -platform:x64 -out:%enusPath%$(TargetName).resources.dll
      
      echo "fr-FR"
      SET frfrPath="$(TargetDir)\Languages\fr-FR"
      if not exist %frfrPath% mkdir %frfrPath%
      %al% -target:lib -embed:%resourcesPath%\Resources.fr-FR.resources,$(ProjectName).Properties.Resources.fr-FR.resources -embed:%resourcesPath%\Errors.fr-FR.resources,$(ProjectName).Properties.Errors.fr-FR.resources -template:$(TargetPath) -culture:fr-FR -platform:x64 -out:%frfrPath%$(TargetName).resources.dll
      
      echo "de-DE"
      SET dedePath="$(TargetDir)\Languages\de-DE"
      if not exist %dedePath% mkdir %dedePath%
      %al% -target:lib -embed:%resourcesPath%\Resources.de-DE.resources,$(ProjectName).Properties.Resources.de-DE.resources -embed:%resourcesPath%\Errors.de-DE.resources,$(ProjectName).Properties.Errors.de-DE.resources -template:$(TargetPath) -culture:de-DE -platform:x64 -out:%dedePath%$(TargetName).resources.dll
      
  2. 告诉资源管理器在 Languages 文件夹中查找附属程序集。我们需要在代码中做到这一点。

    • App.xaml.csApp 构造函数中,处理 SatelliteLocDemo.resources 的 AppDomain.AssemblyResolve 事件:
      public App()
      {
          AppDomain.CurrentDomain.AssemblyResolve += this.CurrentDomain_AssemblyResolve;
      }
      
      private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
      {
          try
          {
              if (args.Name != null && args.Name.StartsWith("SatelliteLocDemo.resources"))
              {
                  string assemblyPath = $"{AppDomain.CurrentDomain.BaseDirectory}\Languages\{Thread.CurrentThread.CurrentUICulture.Name}\SatelliteLocDemo.resources.dll";
                  Assembly assembly = Assembly.LoadFrom(assemblyPath);
                  return assembly;
              }
      
              return null;
          }
          catch (Exception e)
          {
              Trace.WriteLine($"Error loading translations for {args.Name}");
              Trace.WriteLine(e);
              return null;
          }
      }
      
  3. 删除 bin\ 目录,构建并测试您的应用程序。如果使用 updateDll.bat 脚本生成附属程序集,您必须使其适应新结构,或者在其他地方生成所有内容并将附属程序集 copy-paste 放入语言文件夹。

第 3 步:发布和 PublishSingleFile

发布应用程序时,您还需要发布附属程序集。我想你 可以 在 pre-build 事件中生成所有附属程序集,直接进入你的项目结构,并将它们的属性设置为 Content/Copy if newer。这会将它们复制到您的构建目录和发布目录中。这不适用于 PublishSingleFile(或者它可能适用于 ExcludeFromSingleFile,也许不行),所以我选择了不同的方式。本节是在 .net core 3.1 中完成的,但我认为在 .net6 中 PublishSingleFile .exe 不再将内容提取到临时目录中。此处的某些操作在较新版本的 .net 中可能是多余的,但它仍然可以工作。

  1. 我们将在 Publish 事件上添加一个脚本。这个不能直接从 Visual Studio 访问,你必须在你的 .csproj 文件中手动设置它。只需在 PostBuild 部分之后的末尾添加以下行:

      <Target Name="PublishLanguages" AfterTargets="Publish">
        <ItemGroup>
          <LangFiles Include="$(OutDir)\Languages\**\*.*" />
        </ItemGroup>
        <Exec Command="echo Publishing Language files" />
        <Copy SourceFiles="@(LangFiles)" DestinationFiles="@(LangFiles->'$(PublishDir)\Languages\%(RecursiveDir)%(Filename)%(Extension)')" />
      </Target>
    
  2. 为应用添加发布配置文件

    • 文件夹,进入bin\publish
    • Framework-dependent, 调试, win-x64, readyto运行
    • 暂时不要设置为single-file。
  3. 发布并测试您的应用。 Languages 文件夹应该存在于 bin\publish.

  4. 将您的发布配置文件更改为 PublishSingleFile

    • 已发布的应用程序不再找到您的附属程序集,因为构建被提取到临时目录,但附属程序集仍保留在其原始目录中。
  5. 修改 AssemblyResolve 事件回调以查找已发布的 .exe 旁边的附属程序集而不是临时位置

    • CurrentDomain_AssemblyResolve中,将对AppDomain.CurrentDomain.BaseDirectory的调用替换为基于Process.GetCurrentProcess().MainModule的方法:
    public static string GetBasePath()
    {
        using ProcessModule processModule = Process.GetCurrentProcess().MainModule;
        return Path.GetDirectoryName(processModule?.FileName)!;
    }
    
  6. 删除您的 bin\ 目录,然后再次发布并测试您的应用和翻译。现在终于一切都好!