Skip to content

Commit 8b2ad31

Browse files
ui: Lazy-load samples through generated catalog
Add a Roslyn source generator that emits an ISampleCatalogProvider with sample metadata and sample factories at build time. The generated provider lets startup build the category shell and Featured list without reflecting over every sample type or constructing every SampleInfo up front. This reduces launch work and helps avoid the iOS watchdog as the sample set grows. Generated catalog shape: ```csharp public sealed class GeneratedSampleCatalog : ISampleCatalogProvider { public IReadOnlyList<string> GetCategories() => Categories; public IReadOnlyList<string> GetSampleNames() => SampleNames; public SampleInfo CreateSampleInfo(string formalName) { switch (formalName) { case "DisplayMap": return new SampleInfo( formalName: "DisplayMap", sampleName: "Display map", category: "Map", sampleType: typeof(DisplayMap)); default: throw new ArgumentException(...); } } public object CreateSample(string formalName) { switch (formalName) { case "DisplayMap": return new DisplayMap(); default: throw new ArgumentException(...); } } } ``` Old startup flow: ```text AppShell / flyout start | v SampleManager.Initialize() | v Assembly.GetTypes() | v Read attributes and create every SampleInfo | v Build full tree, Featured, Favorites | v First page can render ``` New startup flow: ```text AppShell / flyout start | v SampleManager.Initialize() | v GeneratedSampleCatalog via partial hook | v Load sample names and category names only | v Build category shell and materialize Featured | v First page can render | v Category, search, offline data, and samples load on demand ``` Route SampleManager through the catalog provider API for sample lookup, category loading, all-sample materialization, and sample creation. Search, offline data, and full sample lists still materialize on demand when those flows need them instead of during boot. Keep ReflectionSampleCatalogProvider as a plan B for consumers or targets that do not wire in the generator. The fallback preserves the old reflection-based discovery behavior behind the same ISampleCatalogProvider contract while normal app builds use the generated catalog and avoid startup reflection.
1 parent 85da62c commit 8b2ad31

12 files changed

Lines changed: 839 additions & 99 deletions

File tree

src/Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@
3333
<PackageVersion Include="Microsoft.Maui.Controls" Version="9.0.120" />
3434
<PackageVersion Include="Microsoft.Maui.Controls.Compatibility" Version="9.0.120" />
3535
<PackageVersion Include="Xamarin.AndroidX.AppCompat" Version="1.7.0.6" />
36+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
3637
</ItemGroup>
3738
</Project>

src/MAUI/Maui.Samples/ArcGIS.Samples.Maui.csproj

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@
137137
<PackageReference Include="Microsoft.Maui.Controls" />
138138
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" />
139139
</ItemGroup>
140+
<ItemGroup>
141+
<ProjectReference Include="..\..\Samples.CatalogGenerator\Samples.CatalogGenerator.csproj"
142+
OutputItemType="Analyzer"
143+
ReferenceOutputAssembly="false"
144+
PrivateAssets="all" />
145+
</ItemGroup>
140146

141147
<!-- WinUIEx is used to workaround the lack of a WebAuthenticationBroker for WinUI. https://github.com/microsoft/WindowsAppSDK/issues/441 -->
142148
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
@@ -181,4 +187,4 @@
181187
<BundleResource Include="Platforms\iOS\PrivacyInfo.xcprivacy" LogicalName="PrivacyInfo.xcprivacy" />
182188
</ItemGroup>
183189
<Import Project="..\..\Samples.Shared\ArcGIS.Samples.Shared.projitems" Label="Shared" />
184-
</Project>
190+
</Project>

src/MAUI/Maui.Samples/ViewModels/CategoryViewModel.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public partial class CategoryViewModel : ObservableObject
1818

1919
public CategoryViewModel()
2020
{
21+
SampleManager.Current.Initialize();
22+
2123
// Calculate the sample image width and height on mobile platforms based on device display size.
2224
_sampleImageWidth = 400;
2325

@@ -73,9 +75,7 @@ private void UpdateCategory(string category)
7375

7476
private static List<SampleInfo> GetSamplesInCategory(string category)
7577
{
76-
var categoryNode = SampleManager.Current.FullTree.Items.OfType<SearchableTreeNode>().FirstOrDefault(c => c.Name == category);
77-
78-
return categoryNode.Items.OfType<SampleInfo>().ToList();
78+
return SampleManager.Current.GetSamplesForCategory(category).ToList();
7979
}
8080

8181
[ObservableProperty]

src/Samples.CatalogGenerator/SampleCatalogGenerator.cs

Lines changed: 366 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>netstandard2.0</TargetFramework>
4+
<LangVersion>latest</LangVersion>
5+
<Nullable>enable</Nullable>
6+
<IsRoslynComponent>true</IsRoslynComponent>
7+
<IncludeBuildOutput>false</IncludeBuildOutput>
8+
<NoWarn>$(NoWarn);RS1036;RS2008</NoWarn>
9+
</PropertyGroup>
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
12+
</ItemGroup>
13+
</Project>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2026 Esri.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
5+
//
6+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
7+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
8+
// language governing permissions and limitations under the License.
9+
10+
using ArcGIS.Samples.Shared.Models;
11+
using System.Collections.Generic;
12+
13+
namespace ArcGIS.Samples.Managers
14+
{
15+
/// <summary>
16+
/// Provides generated sample metadata and sample factories without requiring runtime assembly scans.
17+
/// </summary>
18+
public interface ISampleCatalogProvider
19+
{
20+
IReadOnlyList<string> GetCategories();
21+
22+
IReadOnlyList<string> GetSampleNames();
23+
24+
IReadOnlyList<string> GetSampleNamesForCategory(string category);
25+
26+
SampleInfo CreateSampleInfo(string formalName);
27+
28+
object CreateSample(string formalName);
29+
}
30+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2026 Esri.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
5+
//
6+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
7+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
8+
// language governing permissions and limitations under the License.
9+
10+
using ArcGIS.Samples.Shared.Attributes;
11+
using ArcGIS.Samples.Shared.Models;
12+
using System;
13+
using System.Collections.Generic;
14+
using System.Diagnostics;
15+
using System.Linq;
16+
using System.Reflection;
17+
18+
namespace ArcGIS.Samples.Managers
19+
{
20+
/// <summary>
21+
/// Reflection-backed sample catalog used when a generated catalog is not available.
22+
/// </summary>
23+
public sealed class ReflectionSampleCatalogProvider : ISampleCatalogProvider
24+
{
25+
private readonly Dictionary<string, SampleInfo> _samplesByName;
26+
private readonly Dictionary<string, string[]> _sampleNamesByCategory;
27+
private readonly string[] _categories;
28+
private readonly string[] _sampleNames;
29+
30+
public ReflectionSampleCatalogProvider(Assembly samplesAssembly)
31+
{
32+
if (samplesAssembly == null)
33+
{
34+
throw new ArgumentNullException(nameof(samplesAssembly));
35+
}
36+
37+
List<SampleInfo> samples = CreateSampleInfos(samplesAssembly)
38+
.OrderBy(info => info.Category, StringComparer.OrdinalIgnoreCase)
39+
.ThenBy(info => info.SampleName, StringComparer.OrdinalIgnoreCase)
40+
.ToList();
41+
42+
List<SampleInfo> uniqueSamples = samples
43+
.GroupBy(sample => sample.FormalName, StringComparer.OrdinalIgnoreCase)
44+
.Select(group => group.First())
45+
.ToList();
46+
47+
_samplesByName = uniqueSamples.ToDictionary(sample => sample.FormalName, sample => sample, StringComparer.OrdinalIgnoreCase);
48+
_sampleNames = uniqueSamples.Select(sample => sample.FormalName).ToArray();
49+
_categories = uniqueSamples.Select(sample => sample.Category)
50+
.Distinct(StringComparer.OrdinalIgnoreCase)
51+
.OrderBy(category => category, StringComparer.OrdinalIgnoreCase)
52+
.ToArray();
53+
54+
_sampleNamesByCategory = _categories.ToDictionary(
55+
category => category,
56+
category => uniqueSamples
57+
.Where(sample => sample.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
58+
.Select(sample => sample.FormalName)
59+
.ToArray(),
60+
StringComparer.OrdinalIgnoreCase);
61+
}
62+
63+
public IReadOnlyList<string> GetCategories() => _categories;
64+
65+
public IReadOnlyList<string> GetSampleNames() => _sampleNames;
66+
67+
public IReadOnlyList<string> GetSampleNamesForCategory(string category)
68+
{
69+
return !string.IsNullOrEmpty(category) && _sampleNamesByCategory.TryGetValue(category, out string[] sampleNames)
70+
? sampleNames
71+
: Array.Empty<string>();
72+
}
73+
74+
public SampleInfo CreateSampleInfo(string formalName)
75+
{
76+
return GetRequiredSampleInfo(formalName);
77+
}
78+
79+
public object CreateSample(string formalName)
80+
{
81+
SampleInfo sampleInfo = GetRequiredSampleInfo(formalName);
82+
return Activator.CreateInstance(sampleInfo.SampleType);
83+
}
84+
85+
private SampleInfo GetRequiredSampleInfo(string formalName)
86+
{
87+
if (!string.IsNullOrEmpty(formalName) && _samplesByName.TryGetValue(formalName, out SampleInfo sampleInfo))
88+
{
89+
return sampleInfo;
90+
}
91+
92+
throw new ArgumentException($"Unknown sample '{formalName}'.", nameof(formalName));
93+
}
94+
95+
private static IList<SampleInfo> CreateSampleInfos(Assembly assembly)
96+
{
97+
IEnumerable<Type> sampleTypes = assembly.GetTypes()
98+
.Where(type => type.GetTypeInfo().GetCustomAttributes().OfType<SampleAttribute>().Any());
99+
100+
List<SampleInfo> samples = new List<SampleInfo>();
101+
102+
foreach (Type type in sampleTypes)
103+
{
104+
try
105+
{
106+
samples.Add(new SampleInfo(type));
107+
}
108+
catch (Exception ex)
109+
{
110+
Debug.WriteLine("Could not create sample from " + type + ": " + ex);
111+
}
112+
}
113+
114+
return samples;
115+
}
116+
}
117+
}

0 commit comments

Comments
 (0)