Best Practices
Design patterns, conventions, and anti-patterns when working with Beep.Skia
This guide codifies the design patterns and conventions used across all 18 built-in diagram families and the 200+ shipped node types. Following these practices ensures your components integrate cleanly with the rendering pipeline, theme system, serialization engine, and interaction model.
Table of Contents
Component Design
Keep DrawContent() Lightweight
DrawContent() is called on every frame during pan, zoom, and drag operations. A typical diagram with 200 components will call this method 200 times per frame at 60 FPS. Every allocation, path construction, and string measurement adds up quickly.
| Do | Don't |
|---|---|
Pre-compute text measurements in Initialize() |
Call paint.MeasureText() inside DrawContent() |
Cache SKPath objects and only rebuild when geometry changes |
Create new SKPath instances on every draw call |
Use canvas.Save()/canvas.Restore() for localized transforms |
Leave the canvas in a modified state for subsequent draws |
| Use simple hit-test (bounds-based) by default | Implement per-pixel hit-testing unless required for precise selection |
Good: Lightweight DrawContent
private SKPath _cachedPath;
private SKRect _cachedBounds;
protected override void Initialize()
{
base.Initialize();
_cachedPath = new SKPath();
BuildPath();
}
private void BuildPath()
{
_cachedPath.Reset();
_cachedPath.AddRoundRect(new SKRect(0, 0, Width, Height), 8f, 8f);
_cachedBounds = _cachedPath.Bounds;
}
public override void DrawContent(SKCanvas canvas)
{
canvas.Save();
canvas.Translate(X, Y);
canvas.DrawPath(_cachedPath, FillPaint);
canvas.DrawPath(_cachedPath, StrokePaint);
canvas.Restore();
}
SKPaint Pooling
Creating and destroying SKPaint objects is expensive. Pool them at the component level and reuse across draw calls:
Bad: Creating Paints in DrawContent
// DON'T DO THIS
public override void DrawContent(SKCanvas canvas)
{
using var fill = new SKPaint { Color = SKColors.Blue, IsAntialias = true };
using var stroke = new SKPaint { Color = SKColors.Black, Style = SKPaintStyle.Stroke };
canvas.DrawRect(Bounds, fill);
canvas.DrawRect(Bounds, stroke);
}
Good: Pooled Paints
// DO THIS INSTEAD
private SKPaint _fillPaint;
private SKPaint _strokePaint;
protected override void Initialize()
{
base.Initialize();
_fillPaint = new SKPaint { Color = SKColors.Blue, IsAntialias = true };
_strokePaint = new SKPaint
{
Color = SKColors.Black,
Style = SKPaintStyle.Stroke,
IsAntialias = true,
StrokeWidth = 2f
};
}
public override void DrawContent(SKCanvas canvas)
{
canvas.DrawRect(Bounds, _fillPaint);
canvas.DrawRect(Bounds, _strokePaint);
}
public override void Dispose()
{
_fillPaint?.Dispose();
_strokePaint?.Dispose();
base.Dispose();
}
Cache Complex Paths
If a component has a complex shape that only changes when Width or Height change, cache the SKPath and only rebuild it when the component is resized:
Path Caching Pattern
private SKPath _shapePath;
private float _lastWidth, _lastHeight;
private void EnsurePath()
{
if (_shapePath != null && Math.Abs(_lastWidth - Width) < 0.01f
&& Math.Abs(_lastHeight - Height) < 0.01f)
return;
_shapePath?.Dispose();
_shapePath = new SKPath();
// Build complex shape
_shapePath.MoveTo(0, Height * 0.2f);
_shapePath.LineTo(Width * 0.5f, 0);
_shapePath.LineTo(Width, Height * 0.2f);
_shapePath.LineTo(Width * 0.8f, Height);
_shapePath.LineTo(Width * 0.2f, Height);
_shapePath.Close();
_lastWidth = Width;
_lastHeight = Height;
}
Port Management
Always Call MarkPortsDirty() After Position/Size Changes
Connection points are computed lazily. If your component changes position, size, or layout, it must signal that port positions need recomputation. SkiaComponent already calls MarkPortsDirty() from its bounds setters, so call it yourself only when your own properties change port geometry:
Signalling a Port Layout Change
public int PortCount
{
get => _portCount;
set
{
if (_portCount == value) return;
_portCount = value;
MarkPortsDirty(); // inherited from SkiaComponent
InvalidateVisual(); // also repaint the node
}
}
Use Lazy Port Layout
Port geometry is recomputed only when the layout is stale. EnsurePortLayout(() => LayoutPorts()) is the gate: it runs your layout action only while ArePortsDirty is true, then clears the flag. Call it at the top of DrawContent:
Lazy Port Layout Pattern
protected override void DrawContent(SKCanvas canvas, DrawingContext context)
{
EnsurePortLayout(() => LayoutPorts());
// ... draw the node
}
protected override void LayoutPorts()
{
var b = Bounds;
float midY = b.MidY;
InConnectionPoints.Clear();
OutConnectionPoints.Clear();
InConnectionPoints.Add(new ConnectionPoint
{
Type = ConnectionPointType.In,
Center = new SKPoint(b.Left, midY),
Position = new SKPoint(b.Left, midY),
DataType = "object",
Component = this,
IsAvailable = true
});
OutConnectionPoints.Add(new ConnectionPoint
{
Type = ConnectionPointType.Out,
Center = new SKPoint(b.Right, midY),
Position = new SKPoint(b.Right, midY),
DataType = "object",
Component = this,
IsAvailable = true
});
}
Flowchart nodes get this for free: FlowchartControl.DrawContent already calls EnsurePortLayout(() => LayoutPorts()) and then delegates to DrawFlowchartContent, so derived nodes only override LayoutPorts() and DrawFlowchartContent().
Critical
Failing to mark ports dirty after a geometry change results in connection lines detaching from ports or rendering at stale positions. OnBoundsChanged() marks them dirty for moves and resizes; call MarkPortsDirty() yourself when your own properties change port geometry.
Port Count Guidelines
| Component Type | Input Ports | Output Ports | Typical Pattern |
|---|---|---|---|
| Simple node | 1–2 | 1–2 | Left/right or top/bottom pairs |
| Decision/condition node | 1 | 2–4 | Branching paths with labeled ports |
| ERD entity | 0–N | 0–N | Per-field connection points |
| Container/pool | 0 | 0 | No ports — parenting via bounds containment |
Performance
Avoid Allocations in OnPaint
The rendering path is extremely hot. Allocate once in Initialize(), reuse across frames, and dispose in Dispose():
| Allocation Source | Impact | Mitigation |
|---|---|---|
new SKPaint() | GC pressure at 60 FPS | Pool paint objects as class fields |
new SKPath() | Native allocation overhead | Cache and rebuild only on geometry changes |
new SKRect() / new SKPoint() | Stack-allocated structs — free | No action needed; these are value types |
| String concatenation for labels | GC from intermediate strings | Pre-compute display strings in Initialize() |
| LINQ expressions | Enumerable allocations | Use plain for/foreach loops |
Use Pre-Computed SKRect for Bounds
The Bounds property is accessed by hit-testing, spatial indexing, and clipping. Compute it once and invalidate only when position or size changes:
Pre-Computed Bounds
private SKRect _bounds;
public override SKRect Bounds
{
get
{
if (_boundsDirty)
{
_bounds = new SKRect(X, Y, X + Width, Y + Height);
_boundsDirty = false;
}
return _bounds;
}
}
public override void Invalidate()
{
_boundsDirty = true;
base.Invalidate();
}
Batch Canvas Save/Restore
Each canvas.Save()/canvas.Restore() pair pushes/pops a state onto the canvas stack. Minimize nesting depth:
Good: Single Save/Restore Block
public override void DrawContent(SKCanvas canvas)
{
canvas.Save();
canvas.Translate(X, Y);
canvas.ClipRect(new SKRect(0, 0, Width, Height));
DrawBackground(canvas);
DrawIcon(canvas);
DrawLabel(canvas);
DrawSelectionHighlight(canvas);
canvas.Restore();
}
NodeProperties
Use ParameterInfo for Dynamic Properties
Instead of hardcoding property editors in WinForms PropertyGrid controls, expose component configuration through the NodeProperties dictionary. This enables the built-in dynamic property grid to render appropriate editors without reflection:
Property Definition Best Practices
protected override void InitializeComponent()
{
base.InitializeComponent();
NodeProperties["Text"] = new ParameterInfo
{
ParameterName = "Text",
ParameterCurrentValue = "New Node",
DefaultParameterValue = "New Node",
ParameterType = typeof(string),
Description = "Label text"
};
NodeProperties["FillColor"] = new ParameterInfo
{
ParameterName = "FillColor",
ParameterCurrentValue = SKColors.CornflowerBlue,
DefaultParameterValue = SKColors.CornflowerBlue,
ParameterType = typeof(SKColor),
Description = "Fill color"
};
NodeProperties["IsRequired"] = new ParameterInfo
{
ParameterName = "IsRequired",
ParameterCurrentValue = false,
DefaultParameterValue = false,
ParameterType = typeof(bool),
Description = "Required field"
};
NodeProperties["Priority"] = new ParameterInfo
{
ParameterName = "Priority",
ParameterCurrentValue = Priority.Medium,
DefaultParameterValue = Priority.Medium,
ParameterType = typeof(Priority), // enum
Choices = new[] { "Low", "Medium", "High" },
Description = "Priority"
};
NodeProperties["BorderRadius"] = new ParameterInfo
{
ParameterName = "BorderRadius",
ParameterCurrentValue = 8f,
DefaultParameterValue = 8f,
ParameterType = typeof(float),
Description = "Corner radius"
};
}
| ParameterType | Rendered Widget |
|---|---|
string | Single-line text input |
SKColor | Color swatch with picker dialog |
bool | Toggle checkbox |
enum | Dropdown selector (optionally constrained by Choices) |
float / int | Numeric input |
NodeProperties Conventions
Keep ParameterName, the dictionary key and Description consistent across your family, and always set DefaultParameterValue so the property grid can reset values. Use Choices for enumerations that should render as a fixed dropdown.
Connection Validation
Check DataType Compatibility Before Connecting
When users drag connections between ports, validate that the source and target data types are compatible. This prevents runtime errors during data flow and provides immediate visual feedback:
Connection Validation
public static bool CanConnect(IConnectionPoint source, IConnectionPoint target)
{
if (source == null || target == null)
return false;
if (source.Type == target.Type)
return false; // Can't connect two inputs or two outputs
if (ReferenceEquals(source.Component, target.Component))
return false; // Can't self-connect
if (target.Connection != null)
return false; // Target input already has a connection
// Data type compatibility: DataType is a descriptive string (e.g. "string", "number")
if (!string.IsNullOrEmpty(target.DataType) &&
!string.Equals(target.DataType, source.DataType, StringComparison.Ordinal))
return false;
return true;
}
Handle Null Ports Gracefully
Connection operations can encounter null ports when components are being added, removed, or re-laid-out during a drag. Always guard with null checks:
Null-Safe Connection Handling
public void Connect(IConnectionPoint source, IConnectionPoint target)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (target == null) throw new ArgumentNullException(nameof(target));
if (!CanConnect(source, target))
{
LogWarning($"Cannot connect {source.Component?.GetType().Name} to {target.Component?.GetType().Name}");
return;
}
var connection = new ConnectionLine(source, target, () => RequestRedraw());
source.ConnectTo(target);
target.ConnectTo(source);
RequestRedraw();
}
Connection DataType Convention
Ports carry a descriptive DataType string (for example "string", "number", "object") used for data-flow visualization and connection validation. Leave it empty for generic connections (most common for diagramming) and set it explicitly when your family models typed data flow; the DataFlow Diagram (DFD) and ETL families use typed connections extensively.
Anti-Patterns
These patterns appear in naive implementations and cause rendering bugs, memory leaks, or performance degradation. Avoid them.
| Anti-Pattern | Why It's Wrong | Correct Approach |
|---|---|---|
Creating components inside DrawContent() |
Adds components to the drawing manager every frame, causing exponential growth and rendering chaos | Create components in response to user actions (toolbox drag, context menu, keyboard shortcut), never during rendering |
Modifying ConnectionPoints during rendering |
The rendering loop iterates over connection points; mutating the collection causes InvalidOperationException |
Modify ports only in LayoutPorts(), which is called outside the render loop |
Forgetting to Dispose() SKPaint objects |
Native Skia memory leaks. GC does not reclaim unmanaged Skia resources promptly | Override Dispose(), call Dispose() on all SKPaint, SKPath, SKBitmap, and SKSurface fields, then call base.Dispose() |
Calling canvas.Invalidate() inside DrawContent() |
Creates an infinite render loop: draw → invalidate → draw → ... | Invalidate only in response to input events, property changes, or timer callbacks |
Using SKMatrix on canvas directly |
SKMatrix modifies global canvas state; subsequent components inherit the transform | Always wrap transforms in canvas.Save()/canvas.Restore() |
Storing references to SKCanvas |
The canvas object is transient; it changes between frames and between render targets | Accept SKCanvas as a method parameter, never store it as a field |
The Most Dangerous Anti-Pattern
Never create SkiaComponent instances inside DrawContent(). This is the single most destructive anti-pattern. Each frame will add new components to the DrawingManager, which in turn will be drawn on the next frame, adding more components, and so on. The application will freeze within seconds. Component creation must always happen in response to discrete user actions outside the render loop.
Testing
Beep.Skia components are visual, but they are also pure data objects with deterministic rendering output. Unit testing is practical and encouraged.
Rendering Smoke Tests
Verify that every component renders without throwing exceptions and produces non-empty pixel output:
Rendering Smoke Test
[Test]
public void ValveNode_RendersSuccessfully()
{
using var surface = SKSurface.Create(new SKImageInfo(200, 200));
var canvas = surface.Canvas;
canvas.Clear(SKColors.White);
var node = new ValveNode();
node.Initialize();
node.X = 50;
node.Y = 50;
Assert.DoesNotThrow(() => node.DrawContent(canvas));
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
// Verify non-empty output
Assert.That(data.Size, Is.GreaterThan(100), "Rendered output should be non-empty");
node.Dispose();
}
Serialization Round-Trip Tests
Every component family must survive a serialize → deserialize round-trip without property loss:
Serialization Round-Trip Test
[Test]
public void ValveNode_SerializeDeserialize_RoundTrip()
{
var original = new ValveNode();
original.Initialize();
original.X = 150;
original.Y = 200;
original.Width = 80;
original.Height = 100;
original.NodeLabel = "Test Valve";
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<ValveNode>(json);
Assert.That(restored.X, Is.EqualTo(150));
Assert.That(restored.Y, Is.EqualTo(200));
Assert.That(restored.Width, Is.EqualTo(80));
Assert.That(restored.Height, Is.EqualTo(100));
Assert.That(restored.NodeLabel, Is.EqualTo("Test Valve"));
Assert.That(restored.Id, Is.EqualTo(original.Id));
original.Dispose();
restored.Dispose();
}
Port Layout Tests
Verify that ports are positioned correctly and respond to position/size changes:
Port Layout Test
[Fact]
public void ValveNode_PortsUpdateAfterMove()
{
var node = new ValveNode { X = 100, Y = 100 };
var manager = new DrawingManager();
manager.AddComponent(node);
using var bitmap = new SKBitmap(400, 200, SKColorType.Rgba8888, SKAlphaType.Unpremul);
using var canvas = new SKCanvas(bitmap);
manager.Draw(canvas); // first draw computes the port layout
var before = node.OutConnectionPoints[0].Center;
node.X = 300; // bounds change marks the layout dirty
manager.Draw(canvas); // next draw recomputes ports once
var after = node.OutConnectionPoints[0].Center;
Assert.True(after.X > before.X);
Assert.Equal(before.Y, after.Y, 2); // horizontal move, Y unchanged
}
Test Checklist
| Test Category | What to Verify | Priority |
|---|---|---|
| Rendering | No exceptions, non-empty output, correct color at known coordinates | High |
| Serialization | Round-trip preserves Id, position, size, all NodeProperties | High |
| Port Layout | Ports reposition after a move, ports added/removed on size change, lazy layout recomputed once per change | Medium |
| NodeProperties | All properties in dictionary, correct DataType, correct default values | Medium |
| Dispose | No memory leak after 1000 create/dispose cycles, no ObjectDisposedException | Medium |
| Hit Testing | Hits at center, misses at far-outside point, edge behavior at bounds | Low |
| Connection Validation | Valid connections accepted, self-connect rejected, type-mismatch rejected | Low |