SciChart-WPF导出图片遇到VerticalSliceModifier索引越界怎么办?

SciChart WPF 导出稳定性设计:让自定义 Modifier 经得起重复克隆

SciChart WPF 图表在正常显示时运行稳定,却可能在导出图片时出现索引越界、重复 Annotation 或事件状态异常。问题通常不只是一行导出代码,而是运行时交互状态进入了图表克隆流程。本文从生命周期和状态所有权出发,重新设计自定义 VerticalSliceModifier:将业务状态移出 Modifier、保证附加逻辑幂等、隔离临时交互状态,并用重复导出测试验证稳定性。

为什么"显示正常"不等于"可以安全导出"?

业务图表通常不只有数据系列,还可能包含:

  • 跟随鼠标移动的竖向切片线;
  • 用户固定的测量位置;
  • Slice 对应的标签和 Tooltip;
  • Modifier 内部维护的事件订阅;
  • 由 ViewModel 动态生成的 Annotation。

运行时,这些对象只需要服务当前的 SciChartSurface。导出时,图表可能需要准备一份用于渲染的副本。如果自定义逻辑把"第一次加载""重新附加"和"导出克隆"都当成同一种场景,就容易出现重复创建或集合索引不一致。

因此,导出异常首先是一个生命周期问题,其次才是导出 API 问题。

先看官方建议:区分"直接导出"和"克隆后导出"

原论坛问题中,SciChart 工程师首先建议自定义图表检查导出克隆流程,并在需要时重写 CreateCloneOfSurfaceInMemory()。原因是:一旦导出需要创建指定尺寸的内存图表,运行时的 Modifier、Annotation 和绑定关系都可能进入克隆过程。

因此应先判断业务属于哪一种导出:

情况一:按当前图表尺寸直接导出

如果不需要指定新的导出尺寸,可优先使用当前 Surface 直接导出,并保持 useXamlRenderSurface=false。按官方导出故障排查说明,这种方式可以避免不必要地创建内存克隆。

csharp 复制代码
surface.ExportToFile(
    outputPath,
    ExportType.Png,
    useXamlRenderSurface: false);

ExportToFile 的重载在不同 SciChart WPF 版本中可能略有差异,请以项目引用版本的 IntelliSense 为准。关键是不要传入新的 Size,并确认未启用 XAML RenderSurface 导出。

情况二:必须按指定尺寸或自定义结构导出

如果需要高清尺寸、特殊布局或自定义 Surface,官方建议在自定义 SciChartSurface 中重写 CreateCloneOfSurfaceInMemory(),显式处理克隆图表需要的定制内容。

这里不直接给出固定方法签名,因为不同主版本的基类和重载可能有差异。建议在当前项目中输入:

csharp 复制代码
public sealed class ExportableChartSurface : SciChartSurface
{
    // 使用 Visual Studio 的 override 自动完成,
    // 选择当前版本提供的 CreateCloneOfSurfaceInMemory 方法。
}

克隆实现应重点确认:

  • 自定义 Modifier 是否需要进入导出副本;
  • Annotation 是重新根据业务状态创建,还是复制运行时对象;
  • 事件订阅是否只针对当前 Surface;
  • DataContext、资源样式和轴绑定是否能在副本中恢复;
  • 鼠标悬停等临时交互对象是否应被排除。

本文后面的状态分层和幂等设计,不是用来替代官方克隆扩展点,而是让自定义 Modifier 在原 Surface 和克隆 Surface 中都能安全初始化。

第一步:给图表状态分层

我习惯把图表状态分为三类:

状态类型 示例 是否应进入导出结果
业务状态 固定测量线位置、名称、颜色
视图状态 当前缩放范围、主题、轴样式 通常是
临时交互状态 鼠标悬停线、拖动标记、事件句柄 通常否

最危险的设计,是让 Modifier 同时保存这三类状态。更稳妥的方式是把可持久化内容放到独立模型中:

csharp 复制代码
public sealed class SliceMarkerState
{
    public string Id { get; init; } = Guid.NewGuid().ToString("N");
    public double XValue { get; set; }
    public string Label { get; set; } = string.Empty;
    public Color Color { get; set; } = Colors.Orange;
}

public sealed class ChartInteractionState
{
    public ObservableCollection<SliceMarkerState> Markers { get; } = new();
}

Modifier 负责把模型投影成界面对象,但不再把 Annotation 集合作为唯一数据源。这样即使图表需要重建,也能从同一份模型恢复,而不是复制一组来源不明的运行时对象。

第二步:让附加过程具备幂等性

幂等的含义是:同一段初始化逻辑执行一次和执行多次,最终结果一致。

自定义 Modifier 常见的风险写法是:

csharp 复制代码
public override void OnAttached()
{
    base.OnAttached();

    // 每次附加都新增一条线
    VerticalLines.Add(CreateSliceAnnotation());

    // 每次附加都重复订阅
    State.Markers.CollectionChanged += OnMarkersChanged;
}

更稳妥的实现应该先识别自己创建的对象,并把事件订阅设计成可重复调用:

csharp 复制代码
public sealed class StableSliceModifier : VerticalSliceModifier
{
    private const string OwnerPrefix = "stable-slice:";
    private bool _subscribed;

    public ChartInteractionState State { get; set; } = new();

    public override void OnAttached()
    {
        base.OnAttached();

        SubscribeOnce();
        ReconcileVerticalLines();
    }

    public override void OnDetached()
    {
        Unsubscribe();
        base.OnDetached();
    }

    private void SubscribeOnce()
    {
        if (_subscribed)
        {
            return;
        }

        State.Markers.CollectionChanged += OnMarkersChanged;
        _subscribed = true;
    }

    private void Unsubscribe()
    {
        if (!_subscribed)
        {
            return;
        }

        State.Markers.CollectionChanged -= OnMarkersChanged;
        _subscribed = false;
    }

    private void ReconcileVerticalLines()
    {
        var ownedGroups = VerticalLines
            .Where(IsOwned)
            .GroupBy(GetMarkerId)
            .ToDictionary(group => group.Key, group => group.ToList());

        // 清理历史状态中可能已经产生的重复线,每个稳定 ID 只保留一条。
        foreach (var group in ownedGroups.Values)
        {
            foreach (var duplicate in group.Skip(1).ToList())
            {
                VerticalLines.Remove(duplicate);
            }
        }

        var owned = ownedGroups.ToDictionary(
            pair => pair.Key,
            pair => pair.Value[0]);

        var desiredIds = State.Markers
            .Select(marker => marker.Id)
            .ToHashSet();

        // 业务模型已经删除的切片线,也必须从 VerticalLines 移除。
        foreach (var stale in owned
            .Where(pair => !desiredIds.Contains(pair.Key))
            .Select(pair => pair.Value)
            .ToList())
        {
            VerticalLines.Remove(stale);
        }

        foreach (var marker in State.Markers)
        {
            if (owned.TryGetValue(marker.Id, out var annotation))
            {
                UpdateAnnotation(annotation, marker);
                continue;
            }

            VerticalLines.Add(CreateAnnotation(marker));
        }
    }

    private static bool IsOwned(VerticalLineAnnotation annotation) =>
        annotation.Tag is string tag && tag.StartsWith(OwnerPrefix);

    private static string GetMarkerId(VerticalLineAnnotation annotation) =>
        ((string)annotation.Tag).Substring(OwnerPrefix.Length);

    private static VerticalLineAnnotation CreateAnnotation(
        SliceMarkerState marker) =>
        new()
        {
            X1 = marker.XValue,
            Stroke = new SolidColorBrush(marker.Color),
            Tag = OwnerPrefix + marker.Id
        };

    private static void UpdateAnnotation(
        VerticalLineAnnotation annotation,
        SliceMarkerState marker)
    {
        annotation.X1 = marker.XValue;
        annotation.Stroke = new SolidColorBrush(marker.Color);
    }

    private void OnMarkersChanged(
        object? sender,
        NotifyCollectionChangedEventArgs e) =>
        ReconcileVerticalLines();
}

这里真正重要的是 ReconcileVerticalLines():它根据稳定 ID 清理重复项和失效项,再对现有切片线执行新增或更新。即使附加过程再次发生,集合也会收敛到业务模型定义的状态,而不是持续增加竖线。

VerticalSliceModifier.VerticalLines 中的线会由 SciChart 自动加入 SciChartSurface.Annotations。因此,属于该 Modifier 的竖线应通过 VerticalLines 增删;不要只操作 ParentSurface.Annotations。仅从 Annotations 清除对象,并不等同于把它从 VerticalLines 中移除,两边状态可能因此失配。

示例展示的是设计模式。不同 SciChart WPF 版本的 Annotation 属性类型和集合 API 可能略有差异,应以项目引用版本为准。

第三步:导出前冻结临时交互

用户正在拖动切片线时立即导出,可能让导出线程看到一个过渡状态。可以在应用层增加导出会话:

csharp 复制代码
public sealed class ChartExportSession : IDisposable
{
    private readonly StableSliceModifier _sliceModifier;
    private readonly bool _previousEnabled;

    public ChartExportSession(StableSliceModifier sliceModifier)
    {
        _sliceModifier = sliceModifier;
        _previousEnabled = sliceModifier.IsEnabled;
        sliceModifier.IsEnabled = false;
    }

    public void Dispose()
    {
        _sliceModifier.IsEnabled = _previousEnabled;
    }
}

导出时统一走服务:

csharp 复制代码
public static void ExportChart(
    SciChartSurface surface,
    StableSliceModifier sliceModifier,
    string outputPath)
{
    using var session = new ChartExportSession(sliceModifier);

    surface.Dispatcher.Invoke(() =>
    {
        surface.InvalidateElement();
        surface.ExportToFile(outputPath, ExportType.Png);
    });
}

这个过程解决两件事:

  1. 导出期间不再接受新的鼠标交互;
  2. 所有 UI 对象都在 Dispatcher 所在线程访问。

如果项目需要把拖动中的临时线也导出,可以在冻结前先把它提交为正式的 SliceMarkerState,而不是直接复制 Modifier 的内部状态。

第四步:不要把外部控件误认为图表内容

有些 Slice 标签实际上是放在图表外部的 WPF 控件,例如:

  • 右侧详情面板;
  • 浮在窗口上的 Popup;
  • 独立图例;
  • 图表外部的操作按钮。

ExportToFile 面向的是图表渲染结果,不一定包含这些外部视觉元素。若业务要求导出完整工作区,应选择两阶段方案:

  1. 让 SciChart 导出图表本身;
  2. 使用 WPF 的 RenderTargetBitmap 对包含图表和外部控件的容器统一截图。

在设计阶段先明确导出边界,比发生"为什么截图里少了一块"后再修补更省事。

用重复导出测试验证修复

只成功导出一次,不能证明生命周期问题已经解决。建议增加一个压力测试:

csharp 复制代码
[Test]
public void Chart_should_support_repeated_exports()
{
    var initialVerticalLineCount = sliceModifier.VerticalLines.Count;

    for (var index = 0; index < 20; index++)
    {
        var path = Path.Combine(
            TestContext.CurrentContext.WorkDirectory,
            $"chart-{index:00}.png");

        ExportChart(surface, sliceModifier, path);

        Assert.That(File.Exists(path), Is.True);
        Assert.That(
            sliceModifier.VerticalLines.Count,
            Is.EqualTo(initialVerticalLineCount));
    }
}

除了文件存在,还应检查:

  • Annotation 数量是否持续增长;
  • CollectionChanged 是否被重复订阅;
  • 第二次导出的 Slice 位置是否偏移;
  • 切换页面后再次导出是否正常;
  • 导出结束后 Modifier 是否恢复交互;
  • 连续缩放、拖动、导出是否仍稳定。

推荐的排查顺序

遇到 ArgumentOutOfRangeException 或导出克隆异常时,可以按下面顺序检查:

  1. 暂时移除自定义 Modifier,确认基础图表能否导出。
  2. 如果不需要新尺寸,先尝试不传 Size 且使用 useXamlRenderSurface=false
  3. 如果确实进入克隆流程,检查是否需要重写 CreateCloneOfSurfaceInMemory()
  4. 检查 OnAttached() 是否无条件新增 VerticalLines
  5. 检查事件是否成对订阅和取消订阅。
  6. 检查切片线是否拥有稳定 ID 和明确所有者。
  7. 区分业务状态与鼠标临时状态。
  8. 连续导出多次,观察 VerticalLines 数量是否变化。

总结

SciChart WPF 的导出稳定性取决于图表对象是否能够被安全重建。与其围绕某一次索引异常做补丁,不如把自定义 Modifier 设计成可重复附加、可恢复和可验证的组件。

核心原则可以归纳为四句话:

  • 业务状态放在 Modifier 外部;
  • 初始化逻辑必须幂等;
  • 临时交互不要直接进入导出状态;
  • 修复结果要通过重复导出验证。

遵循这些原则后,不仅 VerticalSliceModifier 更稳定,其他带 Annotation、事件订阅和动态状态的自定义 Modifier 也能采用同一套设计。

涉及的 SciChart 类型和导出问题可延伸参考: