Xamarin,如何为项目中的图像文件名生成常量?

2ledvvac  于 2023-08-01  发布在  其他
关注(0)|答案(2)|浏览(85)

我正在寻找一种方法来生成常量类c#文件(s)的图像文件名在我的项目。所以我可以在代码和xaml中使用它们,运行时和设计时,当类重新生成时(当图像文件发生更改时),这将突出潜在的问题。
在过去的一个项目中,我们使用TypeWriter,它使用反射来查看项目文件,并运行我们自己的脚本来生成基于脚本中定义的模板的代码文件。
我讨厌魔法弦,只是想要这种额外的安全级别。
我想要完成,以及Xamarin共享项目,它也需要在iOS和Android项目中可用。
理想情况下,我希望在文件更改时触发脚本,但这可以手动运行。
我正在使用Visual Studio for Mac,所以Nuget包/扩展较少。
我希望可以轻松地扩展此功能,以便在app.xml.cs中为颜色创建常量。

cidc1ykv

cidc1ykv1#

就像其他人在评论中指出的那样,这是源代码生成器的一个很好的用例。
实际上,我想要这个功能已经有一段时间了,所以我继续写了一个概念实现的证明:

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中工作得很好,即使在编辑生成器本身的代码时,它有时需要重新启动,突出显示和完成目前在Rider中由于this而被破坏。
无法在Visual Studio for Mac中测试,抱歉。
我也不确定这是否会很好地集成到Xamarin项目中,但我不认为应该有太多问题。

保存输出

您可以通过将以下内容添加到.csproj将生成器输出保存到磁盘:

<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

<ItemGroup>
    <!-- Exclude the output of source generators from the compilation -->
    <Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs" />
</ItemGroup>


更长的解释可以在这里找到:https://andrewlock.net/creating-a-source-generator-part-6-saving-source-generator-output-in-source-control/

91zkwejq

91zkwejq2#

这是一个关于如何使用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 repo的完整示例:msbuild-fileexplorer的数据。
在VS 2019和Rider中测试。
请记住,我不是一个msbuildMaven,这个解决方案可能会有所改进。

相关问题