Xamarin,如何为项目中的图像文件名生成常量?
Xamarin, how to generate constants for images file names in the project?
我正在寻找一种方法来为我的项目中的图像文件名生成常量 类 c# 文件。
因此,我可以在代码和 xaml、运行时和设计时使用它们,当 类 重新生成时(当图像文件已更改时)这会突出显示潜在问题。
在过去的项目中,我们使用 TypeWriter,它使用反射查看项目文件,运行 我们自己的脚本根据我们脚本中定义的模板生成代码文件。
我讨厌魔术弦,只想要这种额外的安全级别。
我想要完整,以及 Xamarin 共享项目,它还需要在 iOS 和 Android 项目中可用。
理想情况下,我想在文件更改时触发脚本,但这可能是 运行 手动。
我将 Visual Studio 用于 Mac,因此 Nuget 包/扩展较少。
我希望我可以轻松地扩展此功能以在 app.xml.cs 中为颜色创建常量。
就像其他人在评论中指出的那样,这是源生成器的一个很好的用例。
我实际上想要这个功能已经有一段时间了,所以我继续写了一个概念实现的证明:
namespace FileExplorer
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
[Generator]
public class FileExplorerGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
var filesByType = context.AdditionalFiles
.Select(file =>
{
var options = context.AnalyzerConfigOptions.GetOptions(file);
options.TryGetValue("build_metadata.AdditionalFiles.TypeName", out var typeName);
options.TryGetValue("build_metadata.AdditionalFiles.RelativeTo", out var relativeTo);
options.TryGetValue("build_metadata.AdditionalFiles.BrowseFrom", out var browseFrom);
return new { typeName, file.Path, relativeTo, browseFrom };
})
.Where(file => !string.IsNullOrEmpty(file.typeName) && !string.IsNullOrEmpty(file.relativeTo) && !string.IsNullOrEmpty(file.browseFrom))
.GroupBy(file => file.typeName, file => File.Create(file.Path, file.relativeTo!, file.browseFrom!));
foreach (var files in filesByType)
{
var (namespaceName, typeName) = SplitLast(files.Key!, '.');
var root = Folder.Create(typeName, files.Where(file => ValidateFile(file, context)).ToArray());
var result = @$"
namespace {namespaceName ?? "FileExplorer"}
{{
{Generate(root)}
}}";
var formatted = SyntaxFactory.ParseCompilationUnit(result).NormalizeWhitespace().ToFullString();
context.AddSource($"FileExplorer_{typeName}", SourceText.From(formatted, Encoding.UTF8));
}
}
static string Generate(Folder folder)
=> @$"
public static partial class {FormatIdentifier(folder.Name)}
{{
{string.Concat(folder.Folders.Select(Generate))}
{string.Concat(folder.Files.Select(Generate))}
}}";
static string Generate(File file)
{
static string Escape(string segment) => $"@\"{segment.Replace("\"", "\"\"")}\"";
var path = file.RuntimePath
.Append(file.RuntimeName)
.Select(Escape);
return @$"public static readonly string @{FormatIdentifier(file.DesigntimeName)} = System.IO.Path.Combine({string.Join(", ", path)});";
}
static readonly DiagnosticDescriptor invalidFileSegment = new("FE0001", "Invalid path segment", "The path '{0}' contains some segments that are not valid as identifiers: {1}", "Naming", DiagnosticSeverity.Warning, true);
static bool ValidateFile(File file, GeneratorExecutionContext context)
{
static bool IsInvalidIdentifier(string text)
=> char.IsDigit(text[0]) || text.Any(character => !char.IsDigit(character) && !char.IsLetter(character) && character != '_');
var invalid = file.DesigntimePath
.Append(file.DesigntimeName)
.Where(IsInvalidIdentifier)
.ToArray();
if (invalid.Any())
{
var fullPath = Path.Combine(file.RuntimePath.Append(file.RuntimeName).ToArray());
context.ReportDiagnostic(Diagnostic.Create(invalidFileSegment, Location.None, fullPath, string.Join(", ", invalid.Select(segment => $"'{segment}'"))));
}
return !invalid.Any();
}
static string FormatIdentifier(string text)
{
var result = text.ToCharArray();
result[0] = char.ToUpper(result[0]);
return new string(result);
}
static (string?, string) SplitLast(string text, char delimiter)
{
var index = text.LastIndexOf(delimiter);
return index == -1
? (null, text)
: (text.Substring(0, index), text.Substring(index + 1));
}
record File(IReadOnlyList<string> DesigntimePath, IReadOnlyList<string> RuntimePath, string DesigntimeName, string RuntimeName)
{
public IReadOnlyList<string> DesigntimePath { get; } = DesigntimePath;
public IReadOnlyList<string> RuntimePath { get; } = RuntimePath;
public string DesigntimeName { get; } = DesigntimeName;
public string RuntimeName { get; } = RuntimeName;
public static File Create(string absolutePath, string runtimeRoot, string designtimeRoot)
{
static string[] MakeRelative(string absolute, string to) =>
Path.GetDirectoryName(absolute.Replace('/', Path.DirectorySeparatorChar))!
.Split(new[] { to.Replace('/', Path.DirectorySeparatorChar) }, StringSplitOptions.None)
.Last()
.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
var designtimePath = MakeRelative(absolutePath, designtimeRoot);
var runtimePath = MakeRelative(absolutePath, runtimeRoot);
return new File
(
designtimePath,
runtimePath,
Path.GetFileNameWithoutExtension(absolutePath) + Path.GetExtension(absolutePath).Replace('.', '_'),
Path.GetFileName(absolutePath)
);
}
}
record Folder(string Name, IReadOnlyList<Folder> Folders, IReadOnlyList<File> Files)
{
public string Name { get; } = Name;
public IReadOnlyList<Folder> Folders { get; } = Folders;
public IReadOnlyList<File> Files { get; } = Files;
public static Folder Create(string name, IReadOnlyList<File> files)
=> Create(name, files, 0);
static Folder Create(string name, IReadOnlyList<File> files, int level)
{
var folders = files
.Where(file => file.DesigntimePath.Count > level)
.GroupBy(file => file.DesigntimePath[level])
.Select(next => Create(next.Key, next.ToArray(), level + 1))
.ToArray();
return new Folder(name, folders, files.Where(file => file.DesigntimePath.Count == level).ToArray());
}
}
}
}
在您的项目文件中,您将指定文件夹以生成常量,如下所示:
<ItemGroup>
<AdditionalFiles Include="assets\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/assets/mobile" TypeName="MyProject.Definitions.MobileAssets" CopyToOutputDirectory="PreserveNewest" />
<AdditionalFiles Include="lang\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/lang" TypeName="MyProject.Definitions.Languages" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
然后它将生成如下常量:
using MyProject.Definitions;
Console.WriteLine(MobileAssets.App.Ios.Dialog.Cancel_0_1_png);
Console.WriteLine(MobileAssets.Sound.Aac.Damage.Fallsmall_m4a);
Console.WriteLine(Languages.En_US_lang);
由于使用源代码生成器的项目设置有几个移动部分,我将完整的解决方案上传到 github:sourcegen-fileexplorer
编辑器支持仍然有点不稳定,它在 Visual Studio 中运行良好,即使在编辑生成器本身的代码时有时需要重新启动,
由于 this.
,目前在 Rider 中突出显示和完成已损坏
无法在 Visual Studio 中对 Mac 进行测试,抱歉。
我也不确定这是否会很好地集成到 Xamarin 项目中,但我认为应该不会有太多问题。
这是一个示例,说明如何使用 msbuild 而不是我的其他答案中的源代码生成器。
自定义任务:
public class GeneratorTask : Task
{
[Required]
public string OutputFile { get; set; } = "";
[Required]
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
[Required]
public string TypeName { get; set; } = "";
public override bool Execute()
{
if (string.IsNullOrWhiteSpace(OutputFile))
{
Log.LogError($"{nameof(OutputFile)} is not set");
return false;
}
if (string.IsNullOrWhiteSpace(TypeName))
{
Log.LogError($"{nameof(TypeName)} is not set");
return false;
}
try
{
var files = SourceFiles
.Select(item => item.ItemSpec)
.Distinct()
.ToArray();
var code = GenerateCode(files);
var target = new FileInfo(OutputFile);
if (target.Exists)
{
// Only try writing if the contents are different. Don't cause a rebuild
var contents = File.ReadAllText(target.FullName, Encoding.UTF8);
if (string.Equals(contents, code, StringComparison.Ordinal))
{
return true;
}
}
using var file = File.Open(target.FullName, FileMode.Create, FileAccess.Write, FileShare.None);
using var sw = new StreamWriter(file, Encoding.UTF8);
sw.Write(code);
}
catch (Exception e)
{
Log.LogErrorFromException(e);
return false;
}
return true;
}
// Super simple codegen, see my other answer for something more sophisticated.
string GenerateCode(IEnumerable<string> files)
{
var (namespaceName, typeName) = SplitLast(TypeName, '.');
var code = $@"
// Generated code, do not edit.
namespace {namespaceName ?? "FileExplorer"}
{{
public static class {typeName}
{{
{string.Join($"{Environment.NewLine}\t\t", files.Select(GenerateProperty))}
}}
}}";
static string GenerateProperty(string file)
{
var name = file
.ToCharArray()
.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_')
.ToArray();
return $"public static readonly string {new string(name)} = \"{file.Replace("\", "\\")}\";";
}
static (string?, string) SplitLast(string text, char delimiter)
{
var index = text.LastIndexOf(delimiter);
return index == -1
? (null, text)
: (text.Substring(0, index), text.Substring(index + 1));
}
return code;
}
}
FileExplorer.targets 文件:
<Project>
<PropertyGroup>
<ThisAssembly>$(MSBuildThisFileDirectory)bin$(Configuration)$(TargetFramework)$(MSBuildThisFileName).dll</ThisAssembly>
<FirstRun>false</FirstRun>
<FirstRun Condition="!Exists('$(FileExplorerOutputFile)')">true</FirstRun>
</PropertyGroup>
<UsingTask TaskName="$(MSBuildThisFileName).GeneratorTask" AssemblyFile="$(ThisAssembly)" />
<!-- Pointing 'Outputs' to a non existing file will disable up-to-date checks and run the task every time, there's probably a better way -->
<Target Name="FileExplorer" BeforeTargets="BeforeCompile;CoreCompile" Inputs="@(FileExplorerSourceFiles)" Outputs="$(FileExplorerOutputFile).nocache">
<GeneratorTask SourceFiles="@(FileExplorerSourceFiles)" OutputFile="$(FileExplorerOutputFile)" TypeName="$(FileExplorerTypeName)" />
<ItemGroup Condition="Exists('$(FileExplorerOutputFile)')">
<FileWrites Include="$(FileExplorerOutputFile)" />
<Compile Include="$(FileExplorerOutputFile)" Condition="$(FirstRun) == 'true'" />
</ItemGroup>
</Target>
</Project>
然后在您的 .csproj 中:
<PropertyGroup>
<FileExplorerOutputFile>$(MSBuildThisFileDirectory)Assets.g.cs</FileExplorerOutputFile>
<FileExplorerTypeName>FileExplorer.Definitions.Assets</FileExplorerTypeName>
</PropertyGroup>
<ItemGroup>
<FileExplorerSourceFiles Include="assets\**\*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileExplorer\FileExplorer.csproj" />
</ItemGroup>
<Import Project="..\FileExplorer\FileExplorer.targets" />
这是包含完整示例的 github 存储库:msbuild-fileexplorer。
在 VS 2019 和 Rider 中测试。
请记住,我不是 msbuild 专家,此解决方案可能会得到改进。
我正在寻找一种方法来为我的项目中的图像文件名生成常量 类 c# 文件。 因此,我可以在代码和 xaml、运行时和设计时使用它们,当 类 重新生成时(当图像文件已更改时)这会突出显示潜在问题。
在过去的项目中,我们使用 TypeWriter,它使用反射查看项目文件,运行 我们自己的脚本根据我们脚本中定义的模板生成代码文件。
我讨厌魔术弦,只想要这种额外的安全级别。
我想要完整,以及 Xamarin 共享项目,它还需要在 iOS 和 Android 项目中可用。
理想情况下,我想在文件更改时触发脚本,但这可能是 运行 手动。
我将 Visual Studio 用于 Mac,因此 Nuget 包/扩展较少。
我希望我可以轻松地扩展此功能以在 app.xml.cs 中为颜色创建常量。
就像其他人在评论中指出的那样,这是源生成器的一个很好的用例。
我实际上想要这个功能已经有一段时间了,所以我继续写了一个概念实现的证明:
namespace FileExplorer
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
[Generator]
public class FileExplorerGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
var filesByType = context.AdditionalFiles
.Select(file =>
{
var options = context.AnalyzerConfigOptions.GetOptions(file);
options.TryGetValue("build_metadata.AdditionalFiles.TypeName", out var typeName);
options.TryGetValue("build_metadata.AdditionalFiles.RelativeTo", out var relativeTo);
options.TryGetValue("build_metadata.AdditionalFiles.BrowseFrom", out var browseFrom);
return new { typeName, file.Path, relativeTo, browseFrom };
})
.Where(file => !string.IsNullOrEmpty(file.typeName) && !string.IsNullOrEmpty(file.relativeTo) && !string.IsNullOrEmpty(file.browseFrom))
.GroupBy(file => file.typeName, file => File.Create(file.Path, file.relativeTo!, file.browseFrom!));
foreach (var files in filesByType)
{
var (namespaceName, typeName) = SplitLast(files.Key!, '.');
var root = Folder.Create(typeName, files.Where(file => ValidateFile(file, context)).ToArray());
var result = @$"
namespace {namespaceName ?? "FileExplorer"}
{{
{Generate(root)}
}}";
var formatted = SyntaxFactory.ParseCompilationUnit(result).NormalizeWhitespace().ToFullString();
context.AddSource($"FileExplorer_{typeName}", SourceText.From(formatted, Encoding.UTF8));
}
}
static string Generate(Folder folder)
=> @$"
public static partial class {FormatIdentifier(folder.Name)}
{{
{string.Concat(folder.Folders.Select(Generate))}
{string.Concat(folder.Files.Select(Generate))}
}}";
static string Generate(File file)
{
static string Escape(string segment) => $"@\"{segment.Replace("\"", "\"\"")}\"";
var path = file.RuntimePath
.Append(file.RuntimeName)
.Select(Escape);
return @$"public static readonly string @{FormatIdentifier(file.DesigntimeName)} = System.IO.Path.Combine({string.Join(", ", path)});";
}
static readonly DiagnosticDescriptor invalidFileSegment = new("FE0001", "Invalid path segment", "The path '{0}' contains some segments that are not valid as identifiers: {1}", "Naming", DiagnosticSeverity.Warning, true);
static bool ValidateFile(File file, GeneratorExecutionContext context)
{
static bool IsInvalidIdentifier(string text)
=> char.IsDigit(text[0]) || text.Any(character => !char.IsDigit(character) && !char.IsLetter(character) && character != '_');
var invalid = file.DesigntimePath
.Append(file.DesigntimeName)
.Where(IsInvalidIdentifier)
.ToArray();
if (invalid.Any())
{
var fullPath = Path.Combine(file.RuntimePath.Append(file.RuntimeName).ToArray());
context.ReportDiagnostic(Diagnostic.Create(invalidFileSegment, Location.None, fullPath, string.Join(", ", invalid.Select(segment => $"'{segment}'"))));
}
return !invalid.Any();
}
static string FormatIdentifier(string text)
{
var result = text.ToCharArray();
result[0] = char.ToUpper(result[0]);
return new string(result);
}
static (string?, string) SplitLast(string text, char delimiter)
{
var index = text.LastIndexOf(delimiter);
return index == -1
? (null, text)
: (text.Substring(0, index), text.Substring(index + 1));
}
record File(IReadOnlyList<string> DesigntimePath, IReadOnlyList<string> RuntimePath, string DesigntimeName, string RuntimeName)
{
public IReadOnlyList<string> DesigntimePath { get; } = DesigntimePath;
public IReadOnlyList<string> RuntimePath { get; } = RuntimePath;
public string DesigntimeName { get; } = DesigntimeName;
public string RuntimeName { get; } = RuntimeName;
public static File Create(string absolutePath, string runtimeRoot, string designtimeRoot)
{
static string[] MakeRelative(string absolute, string to) =>
Path.GetDirectoryName(absolute.Replace('/', Path.DirectorySeparatorChar))!
.Split(new[] { to.Replace('/', Path.DirectorySeparatorChar) }, StringSplitOptions.None)
.Last()
.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
var designtimePath = MakeRelative(absolutePath, designtimeRoot);
var runtimePath = MakeRelative(absolutePath, runtimeRoot);
return new File
(
designtimePath,
runtimePath,
Path.GetFileNameWithoutExtension(absolutePath) + Path.GetExtension(absolutePath).Replace('.', '_'),
Path.GetFileName(absolutePath)
);
}
}
record Folder(string Name, IReadOnlyList<Folder> Folders, IReadOnlyList<File> Files)
{
public string Name { get; } = Name;
public IReadOnlyList<Folder> Folders { get; } = Folders;
public IReadOnlyList<File> Files { get; } = Files;
public static Folder Create(string name, IReadOnlyList<File> files)
=> Create(name, files, 0);
static Folder Create(string name, IReadOnlyList<File> files, int level)
{
var folders = files
.Where(file => file.DesigntimePath.Count > level)
.GroupBy(file => file.DesigntimePath[level])
.Select(next => Create(next.Key, next.ToArray(), level + 1))
.ToArray();
return new Folder(name, folders, files.Where(file => file.DesigntimePath.Count == level).ToArray());
}
}
}
}
在您的项目文件中,您将指定文件夹以生成常量,如下所示:
<ItemGroup>
<AdditionalFiles Include="assets\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/assets/mobile" TypeName="MyProject.Definitions.MobileAssets" CopyToOutputDirectory="PreserveNewest" />
<AdditionalFiles Include="lang\**\*" RelativeTo="MyProject" BrowseFrom="MyProject/lang" TypeName="MyProject.Definitions.Languages" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
然后它将生成如下常量:
using MyProject.Definitions;
Console.WriteLine(MobileAssets.App.Ios.Dialog.Cancel_0_1_png);
Console.WriteLine(MobileAssets.Sound.Aac.Damage.Fallsmall_m4a);
Console.WriteLine(Languages.En_US_lang);
由于使用源代码生成器的项目设置有几个移动部分,我将完整的解决方案上传到 github:sourcegen-fileexplorer
编辑器支持仍然有点不稳定,它在 Visual Studio 中运行良好,即使在编辑生成器本身的代码时有时需要重新启动,
由于 this.
,目前在 Rider 中突出显示和完成已损坏
无法在 Visual Studio 中对 Mac 进行测试,抱歉。
我也不确定这是否会很好地集成到 Xamarin 项目中,但我认为应该不会有太多问题。
这是一个示例,说明如何使用 msbuild 而不是我的其他答案中的源代码生成器。
自定义任务:
public class GeneratorTask : Task
{
[Required]
public string OutputFile { get; set; } = "";
[Required]
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
[Required]
public string TypeName { get; set; } = "";
public override bool Execute()
{
if (string.IsNullOrWhiteSpace(OutputFile))
{
Log.LogError($"{nameof(OutputFile)} is not set");
return false;
}
if (string.IsNullOrWhiteSpace(TypeName))
{
Log.LogError($"{nameof(TypeName)} is not set");
return false;
}
try
{
var files = SourceFiles
.Select(item => item.ItemSpec)
.Distinct()
.ToArray();
var code = GenerateCode(files);
var target = new FileInfo(OutputFile);
if (target.Exists)
{
// Only try writing if the contents are different. Don't cause a rebuild
var contents = File.ReadAllText(target.FullName, Encoding.UTF8);
if (string.Equals(contents, code, StringComparison.Ordinal))
{
return true;
}
}
using var file = File.Open(target.FullName, FileMode.Create, FileAccess.Write, FileShare.None);
using var sw = new StreamWriter(file, Encoding.UTF8);
sw.Write(code);
}
catch (Exception e)
{
Log.LogErrorFromException(e);
return false;
}
return true;
}
// Super simple codegen, see my other answer for something more sophisticated.
string GenerateCode(IEnumerable<string> files)
{
var (namespaceName, typeName) = SplitLast(TypeName, '.');
var code = $@"
// Generated code, do not edit.
namespace {namespaceName ?? "FileExplorer"}
{{
public static class {typeName}
{{
{string.Join($"{Environment.NewLine}\t\t", files.Select(GenerateProperty))}
}}
}}";
static string GenerateProperty(string file)
{
var name = file
.ToCharArray()
.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_')
.ToArray();
return $"public static readonly string {new string(name)} = \"{file.Replace("\", "\\")}\";";
}
static (string?, string) SplitLast(string text, char delimiter)
{
var index = text.LastIndexOf(delimiter);
return index == -1
? (null, text)
: (text.Substring(0, index), text.Substring(index + 1));
}
return code;
}
}
FileExplorer.targets 文件:
<Project>
<PropertyGroup>
<ThisAssembly>$(MSBuildThisFileDirectory)bin$(Configuration)$(TargetFramework)$(MSBuildThisFileName).dll</ThisAssembly>
<FirstRun>false</FirstRun>
<FirstRun Condition="!Exists('$(FileExplorerOutputFile)')">true</FirstRun>
</PropertyGroup>
<UsingTask TaskName="$(MSBuildThisFileName).GeneratorTask" AssemblyFile="$(ThisAssembly)" />
<!-- Pointing 'Outputs' to a non existing file will disable up-to-date checks and run the task every time, there's probably a better way -->
<Target Name="FileExplorer" BeforeTargets="BeforeCompile;CoreCompile" Inputs="@(FileExplorerSourceFiles)" Outputs="$(FileExplorerOutputFile).nocache">
<GeneratorTask SourceFiles="@(FileExplorerSourceFiles)" OutputFile="$(FileExplorerOutputFile)" TypeName="$(FileExplorerTypeName)" />
<ItemGroup Condition="Exists('$(FileExplorerOutputFile)')">
<FileWrites Include="$(FileExplorerOutputFile)" />
<Compile Include="$(FileExplorerOutputFile)" Condition="$(FirstRun) == 'true'" />
</ItemGroup>
</Target>
</Project>
然后在您的 .csproj 中:
<PropertyGroup>
<FileExplorerOutputFile>$(MSBuildThisFileDirectory)Assets.g.cs</FileExplorerOutputFile>
<FileExplorerTypeName>FileExplorer.Definitions.Assets</FileExplorerTypeName>
</PropertyGroup>
<ItemGroup>
<FileExplorerSourceFiles Include="assets\**\*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileExplorer\FileExplorer.csproj" />
</ItemGroup>
<Import Project="..\FileExplorer\FileExplorer.targets" />
这是包含完整示例的 github 存储库:msbuild-fileexplorer。
在 VS 2019 和 Rider 中测试。
请记住,我不是 msbuild 专家,此解决方案可能会得到改进。