Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ and follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### 메모리 — 스크롤·숨김·글꼴 목록이 남기던 것 (2026-09-27)

지금까지의 메모리 측정은 전부 **맨 위에 놓인 문서**를 쟀다. 렌더 경로는 **그린 것**을 캐시하는데, 문서를 끝까지 한 번
넘기면 전부를 그린다. `samples/MemBaseline`에 스크롤 시나리오를 넣어 처음 쟀다(Release, Intel UHD, Private bytes).

| 2,000문단 + 사진 100장 | 전 | 후 |
|---|---|---|
| 끝까지 스크롤 | 501 MB | **257 MB** |
| 다시 맨 위로 | 499 MB | **217 MB** |
| 에디터를 시각 트리에서 뺌(숨긴 탭 등) | 482 MB | **185 MB** |
| 다시 넣음 | 481 MB | **150 MB** |

- **화면에서 먼 그림은 디코드를 놓는다.** 문서에 있는 그림은 한 번 그려지면 문서가 열려 있는 한 계속 디코드된 채였다.
이제 화면과 위아래 한 화면씩을 뺀 곳의 그림은 가장 오래전에 그린 것부터 해제하고, 남는 양을 32 MB로 묶는다. 다시
화면에 들어온 그림은 다시 디코드되며, 그동안 자리표시가 보인다. 편집으로 지운 그림은 전처럼 되돌리기용 예산을 따로 쓴다.
- **문단 레이아웃 캐시가 스크롤에 비례해 자랐다.** 뷰포트 크기일 거라던 캐시가 끝까지 스크롤하면 2,000문단 전부를
들고 있었다(2048에서 전부 비우는 안전망까지). 이제 그리기가 끝날 때마다 최근에 쓴 256개로 줄인다(텍스트 전용 2,000문단:
스크롤 뒤 152 → 136 MB). 줄어든 문단은 다시 보일 때 레이아웃을 새로 만든다 — CPU만 쓰고 화면엔 차이가 없다.
- **시각 트리에서 빠진 에디터가 아무것도 놓지 않았다.** 이제 빠질 때 레이아웃을 모두 놓고, 화면 근처가 아닌 그림을
해제한다(화면에 있던 그림은 남겨 돌아올 때 바로 보인다).
- **그림을 해제한 뒤 Win2D 디바이스를 다듬는다**(`CanvasDevice.Trim`). 비트맵을 버려도 드라이버가 올리기에 쓴 메모리를
쥐고 있었다 — 맨 위로 돌아온 뒤 Private 38 MB·GPU 70 MB를 더 돌려준다. 호출은 마지막 해제 2초 뒤 한 번(약 0.7 ms)이다.
- **글꼴 목록이 설치된 글꼴을 전부 열었다.** 툴바가 처음 글꼴 목록을 물을 때 +31.6 MB·UI 스레드 843 ms였고, 관리 힙이
거의 안 늘어 GC가 돌 이유가 없는 앱에서는 그대로 남았다. 이제 +0.7 MB·140 ms이고 이름은 같다(이전 방식과 이름 단위 비교).
- 그림 이미지 교체 뒤 레이아웃 캐시를 비우던 곳이 네이티브 레이아웃을 Dispose하지 않았다(파이널라이저 몫) — 고쳤다.
- 참고: 빈 에디터 하나의 고정비(~37 MB)는 **전부 Win2D 디바이스**다 — 맨 `CanvasVirtualControl` 하나(111.6 MB)가 빈
에디터(108.3 MB)보다 크다. 라이브러리 자체의 고정비는 없다.

### 다른 앱에서 보이는 HTML — 상류 라운드 9 백포트 (2026-09-24)

상류가 2026-08-08에 브라우저로 **측정**해서 고친 HTML 결함 중 두 건이 이 포트에 없었다. 둘 다 이 편집기의 HTML
Expand Down
27 changes: 25 additions & 2 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
|---|---|
| 릴리스 | 1.0.0(07-31, **API 동결**) → 1.1.0(08-07) → 1.1.1(09-06, AOT 결함 수정) → **1.2.0(09-20 게시)** |
| 빌드 | 라이브러리 0 warn / 0 err (테스트 프로젝트에 xUnit1031 1건 — 블로킹 대기, 기존) |
| 테스트 | **930**(2026-09-24, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 |
| 테스트 | **951**(2026-09-27, 1.2.0 게시 시 815, 1.0 시점 106). OS 클립보드 테스트는 경합으로 간헐적 빨강 — 단독 재실행으로 확인 |
| AOT | self-contained 게시 성공(2026-09-20): 네이티브 exe **15.2MB**, 게시 **74.6MB**(pdb 제외), CoreCLR·관리 dll 없음 |
| 공개 표면 | **574** — `PublicAPI.Shipped.txt`로 추적, 1.2.0은 **추가 7**(breaking 없음) |

Expand Down Expand Up @@ -104,6 +104,28 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
기하 표기, RTF 용지)은 이미 있었다. `HtmlExternalRenderingTests`(8), 반증 3종, 퍼즈 20,000시드 통과.
**실기 확인 완료**(2026-09-24, 사용자): 빈 줄을 복사해 Word·HWP에 붙이면 한 줄로 들어가고, 셀 속 그림은 캡션 아래에 온다. PR #57 머지.

### 메모리 쥐어짜기 (2026-09-27) — 테스트 +12, 반증 15/15, AOT 프로브 동일
처음으로 **읽는 동안**의 메모리를 쟀다(`MemBaseline` `MEMTEST_MODE=scroll`: 끝까지 넘김 → 맨 위 → 트리에서 뺌 → 다시
넣음, `win2d`: 맨 `CanvasVirtualControl`, `fonts`: 글꼴 목록). 수치는 CHANGELOG. 순서대로 효과가 컸다:
1. **화면에서 먼 그림 해제**(`ImageCache.TrimOffscreen`, 드로 패스 끝) — 그림은 마지막으로 그려진 **컨트롤 좌표**와
사용 순서를 기록하고, 뷰포트 ±1화면 밖을 LRU로 32 MB(`ImageOffscreenBytes`)까지 줄인다. 보호 판정을 "이번 패스에
그렸나"로 하면 안 된다 — 스크롤은 새로 드러난 띠만 다시 그리므로, 화면에 있는 그림도 여러 패스 동안 안 그려진다.
2. **레이아웃 캐시 LRU**(`TrimLayoutCache`, 256 + 여유 128) — 드로 패스 끝에서만 한다. 그때는 어느 걷기도 레이아웃을
쥐고 있지 않아 쓰는 중인 것을 Dispose할 수 없다(2048 clear-all은 그 보장이 없어서 캡을 크게 둔 것).
3. **Unloaded 해제** + **디바이스 Trim**(해제 2초 뒤 디바운스). 텍스트만 스크롤 뒤의 GPU 증가분(~90 MB)은 대부분 XAML
가상 표면 타일이라 Trim으로 안 준다(4 MB) — 스크롤 멈춤마다 Trim하지 않는 이유.
4. **글꼴 목록** — `CanvasTextFormat.GetSystemFontFamilies(string[])`. ⚠ **AOT 함정 두 개를 프로브가 잡았다**(JIT 테스트는
통과): `CanvasFontSet.GetPropertyValues`는 비blittable 구조체 배열(`CanvasFontProperty[]`)이라 AOT에서 실패, 로캘 목록을
컬렉션 식 `[..]`으로 넘기면 컴파일러 합성 형식이라 CCW 생성 실패. 둘 다 조용히 기본 글꼴 6개로 폴백했다.
데모 `--pageprobe`에 `fonts` 줄(개수 + FNV 해시)을 넣어 `fault-sweep`이 JIT/AOT를 diff한다.

- 레버가 아닌 것(측정으로 확인): **고정비 ~37 MB는 전부 Win2D**(맨 CanvasVirtualControl 111.6 > 빈 에디터 108.3 MB) ·
되돌리기(이미 요소당 155 B로 실측 보정) · 모델 객체(런 ~100 B) · 텍스트 로드의 네이티브 증가분(DirectWrite 몫, 문단 수에 준선형).
- **상류 후속 후보**: 상류 `_layoutCache`는 **측정 경로도** 같은 캐시를 써서 문서 전체(최대 10,000)를 들고 있다 — 포트가
2026-06에 높이/레이아웃을 분리하기 전 모양. Avalonia `TextLayout`은 관리 객체라 규모는 따로 재야 한다(계측 먼저).
- **실기 확인 완료**(2026-09-28, 사용자, 문제 없음): 긴 사진 문서를 끝까지 스크롤했다 돌아올 때의 자리표시, 탭 전환 뒤
그림 표시, 툴바 글꼴 목록 전체. PR #59.

### 클립보드 경합 재시도 (2026-09-24, PR #55)
클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜
붙여넣기 6%·복사 6%가 조용히 실패했다(개발 PC 실측). 그 오류만 **await**로 20 ms × 10회 재시도 — `Thread.Sleep`은
Expand Down Expand Up @@ -234,7 +256,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
(우회로: 7-Zip AES / BitLocker·VeraCrypt로 감싸면 된다).
- **미측정 성능 후보 2건**(옛 `implementation_plan.md`에서 살아남은 것 — 그 파일은 2026-09-20에 지웠다.
나머지 제안은 이미 반영됐거나(`FindCell`은 `Parent` 기반) 근거가 없었다):
① `EvictLayouts()`가 캐시를 **전부** 비운다 — 바로 위 주석은 "가장 오래된 것만"이라고 말한다(주석/코드 불일치).
~~① `EvictLayouts()`가 캐시를 전부 비운다~~ → 2026-09-27: 주석을 "안전망"으로 고치고, 평상시 경계는 드로 패스 끝의
LRU(`TrimLayoutCache`)가 맡는다.
② `GetImageCount()`가 호출마다 트리를 훑는다(`AfterEdit` 경로).
⚠ 둘 다 **계측부터** 할 것 — 이 리포에서 읽어서 세운 성능 가설은 전부 빗나갔다.

Expand Down
68 changes: 68 additions & 0 deletions samples/MemBaseline/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ namespace MemBaseline;
/// reclaim — load big doc, then clear it + GC, logging WS before/after (reclaim/leak check)
/// images — insert photos / delete them by EDITING / swap / drop history, logging Priv AND GPU memory
/// (see ImageScenario)
/// scroll page through a long document to its end, then unload/reload the editor (see ScrollScenario)
/// win2d the bare CanvasVirtualControl the editor draws on — editor minus this is the library's own cost
/// fonts what the toolbar's font list costs (MEMTEST_FONTPROBE=1 also compares two ways of reading it)
/// Results are read externally via Get-Process; reclaim mode also writes a small text log.
/// </summary>
public sealed class MainWindow : Window
Expand All @@ -38,6 +41,23 @@ public MainWindow()
return;
}

// Win2D alone: the bare CanvasVirtualControl the editor is built on, in a ScrollViewer, drawing one
// line of text. editor − win2d = what the library itself adds on top of its render backend.
if (mode == "win2d")
{
var cvc = new Microsoft.Graphics.Canvas.UI.Xaml.CanvasVirtualControl { Width = 800, Height = 600 };
cvc.RegionsInvalidated += (s, e) =>
{
foreach (var r in e.InvalidatedRegions)
{
using var ds = s.CreateDrawingSession(r);
ds.DrawText("win2d baseline", 10, 10, Microsoft.UI.Colors.Black);
}
};
root.Children.Add(new ScrollViewer { Content = cvc });
return;
}

// Multi-instance: N empty RichEditors in the visual tree, each sized so it loads and
// initializes its Win2D render path. Reveals whether the CanvasDevice is shared across
// instances (small per-instance increment) or per-control (large increment).
Expand Down Expand Up @@ -84,6 +104,54 @@ public MainWindow()
editor.Document = new FlowDocument();
editor.Loaded += async (_, _) => await new ImageScenario(editor).RunAsync();
break;
case "scroll":
AppWindow.Resize(new Windows.Graphics.SizeInt32(1100, 900));
editor.Document = new FlowDocument();
RoutedEventHandler? once = null;
once = async (_, _) => { editor.Loaded -= once; await new ScrollScenario(editor, root).RunAsync(); };
editor.Loaded += once;
break;
case "fonts":
// What the toolbar's font list costs: FontFamilyChoices enumerates the system font set once.
editor.Document = new FlowDocument();
editor.Loaded += async (_, _) =>
{
var path = Path.Combine(Path.GetTempPath(), "membaseline_fonts.txt");
void L(string s)
{
var pr = Process.GetCurrentProcess(); pr.Refresh();
File.AppendAllText(path, $"{s,-10} Priv={pr.PrivateMemorySize64 / 1048576.0,7:N1}MB managed={GC.GetTotalMemory(false) / 1048576.0,6:N1}MB\n");
}
async System.Threading.Tasks.Task Settle() { await System.Threading.Tasks.Task.Delay(1500); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); await System.Threading.Tasks.Task.Delay(500); }
File.AppendAllText(path, $"\n=== fonts {DateTime.Now:HH:mm:ss}\n");
await Settle(); L("before");
if (Environment.GetEnvironmentVariable("MEMTEST_FONTPROBE") == "1")
{
var psw = Stopwatch.StartNew();
string locale = System.Globalization.CultureInfo.CurrentUICulture.Name.ToLowerInvariant();
string lang = locale.Length >= 2 ? locale[..2] : locale;
using var set = Microsoft.Graphics.Canvas.Text.CanvasFontSet.GetSystemFontSet();
var props = set.GetPropertyValues(Microsoft.Graphics.Canvas.Text.CanvasFontPropertyIdentifier.FamilyName, $"{locale};{lang};en-us");
var viaProps = new System.Collections.Generic.SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pv in props) if (!string.IsNullOrWhiteSpace(pv.Value)) viaProps.Add(pv.Value);
double pms = psw.Elapsed.TotalMilliseconds;
L("props"); File.AppendAllText(path, $" {viaProps.Count} via GetPropertyValues in {pms:N0}ms; locales seen: {string.Join(",", System.Linq.Enumerable.Distinct(System.Linq.Enumerable.Select(props, x => x.Locale)))}\n");
await Settle(); L("props-gc");
var viaFaces = new System.Collections.Generic.SortedSet<string>(editor.FontFamilyChoices, StringComparer.OrdinalIgnoreCase);
var onlyFaces = new System.Collections.Generic.List<string>(viaFaces); onlyFaces.RemoveAll(viaProps.Contains);
var onlyProps = new System.Collections.Generic.List<string>(viaProps); onlyProps.RemoveAll(viaFaces.Contains);
File.AppendAllText(path, $" faces={viaFaces.Count} props={viaProps.Count}\n only in faces: {string.Join(" | ", onlyFaces)}\n only in props: {string.Join(" | ", onlyProps)}\n");
File.AppendAllText(path, "DONE\n");
return;
}
var sw = Stopwatch.StartNew();
int n = editor.FontFamilyChoices.Count;
double ms = sw.Elapsed.TotalMilliseconds;
L("listed"); File.AppendAllText(path, $" {n} families in {ms:N0}ms\n");
await Settle(); L("gc");
File.AppendAllText(path, "DONE\n");
};
break;
case "reclaim":
editor.Document = HtmlDocumentFormatter.ParseHtml(BigHtml(200));
ScheduleReclaim(editor);
Expand Down
Loading
Loading