Pantrix UI

Web'de geç gelen yazı tipi

Compose Multiplatform'un web hedefinde bir uygulamanın kendi yazı tipinin neden bir an geç geldiği, bunu nasıl ölçtüğümüz ve nasıl giderdiğimiz. Sorun Pantrix'e özgü değildir: web hedefi olan ve kendi font dosyasını kullanan her Compose Multiplatform projesinde aynı şekilde görülür.

1. Sorun

Belirti

Web sayfalarında bunun adı FOUT'tur (flash of unstyled text).

Neden

Compose Multiplatform'da bir font dosyası Font(Res.font.…) ile okunur. Bu fonksiyon platforma göre iki ayrı şey yapar (Compose Multiplatform 1.12.1, components-resources):

PlatformOkumaİlk karede
Android, iOS, masaüstüEşzamanlı: dosya, istendiği anda okunur (ResourceState.blocking.kt)Yazı tipi hazırdır
Web (js, wasmJs)Eşzamansız: dosya fetch ile indirilir (ResourceState.web.kt)Yazı tipi yoktur

Web'de Font(…), dosya gelene dek bir yedek döndürür: Skiko'nun içinde taşıdığı Roboto (FontResources.skiko.kt, defaultFont). Dosya gelince döndürdüğü değer değişir, onu okuyan her şey yeniden kurulur ve her metin yeniden yerleşir. Üç ayrıntı sorunu büyütür:

  1. Okuma geç başlar. Font(…) bir composable'dır: indirme ancak uygulama ilk kez kurulurken, yani Wasm indirilip derlendikten sonra başlar.

  2. Her ağırlık ayrı bir fonttur. Önbelleğin anahtarı dosyanın yolu, ağırlığı, stili ve eksen ayarıdır. Yedi ağırlıkta kaydedilen tek bir değişken font yedi kez okunur ve yedi ayrı anda gelebilir: tema birkaç kez baştan kurulur.

  3. Her yüklemede yeniden istenir. Kaynakların tarayıcıdaki önbelleği (Cache API) her sayfa yüklemesinde sıfırlanır (ResourceWebCache.web.kt); dosyayı tarayıcının kendi HTTP önbelleği verir.

Ölçüm

Üretim paketi (wasmJsBrowserDistribution) yerel bir sunucudan açıldı; zamanlar sayfaya eklenen bir ölçüm betiğiyle okundu (bkz. 6. bölüm). Sayılar sayfa açıldıktan sonraki milisaniyelerdir.

OlayDüzeltmeden önceDüzeltmeden sonra
Yazı tipi dosyaları istendi408 (uygulama kurulduktan sonra; yalnızca temanın dosyası)71 (kod inerken, <link rel="preload">; on beş dosyanın hepsi)
Bütün fontlar okundu440274
Uygulamanın ilk karesi401, yedek yazı tipiyle456, kendi yazı tipiyle
Harfler değişti500 dolayındaHiç
"Ayarlar"da başka bir yazı tipi seçildiYedi dosya o anda istenir; gelene dek yedek yazı tipiYeni istek yok; bir karede değişir

Yerelde ağ gecikmesi yoktur ve fark yüz milisaniyedir; gerçek bir ağda yazı tipi geç geldiği kadar uzun sürer. İlk karenin kendisi iki ölçümde de 175 ile 240 ms arasında sürdü: yazı tipini beklemek açılışı geciktirmedi, çünkü dosyalar o kare kurulmadan çok önce inmişti.

2. Çözüm: dört parça

ParçaNe sağlar
1Erken indir. index.html'de <link rel="preload">Dosya, uygulamanın kodu inerken iner; uygulama istediğinde tarayıcının önbelleğindedir
2Önce oku, sonra çiz. Uygulamayı bir kapının arkasına alUygulama ilk karesini kendi yazı tipiyle çizer; harfler değişmez
3Yükleme işaretini tut. Uygulamayı gövdeye değil, kendi kutusuna kurBeklerken boş sayfa değil, sayfanın yükleme işareti görünür
4Değişen yazı tipinde eskisinde kal. Yenisi gelince bir kez değişÇalışırken yazı tipi değiştirmek de yedek yazı tipini göstermez

İkinci parça işin özüdür ve Compose'un bir özelliğine dayanır: okunan font bellekte kalır ve onu sonradan isteyen ilk kurulumda hemen bulur. Yani yazı tipini önce bir yerde sorup (preloadFont) uygulamayı ondan sonra kurmak yeter; uygulamanın kendisinde hiçbir şey değişmez.

3. Pantrix ile

Kütüphane dört parçanın Kotlin tarafını verir (com.pantrixui.foundation.token.typography):

APINe yapar
PantrixFont(resource, weight, style, variationSettings)Bir font: kaynaklardaki dosya ve ailenin onu kaydettiği ağırlık ile stil. variationSettings değişken bir fontun eksenlerindeki yeridir; verilmezse ağırlık ile stilin söylediğidir. Önden okuma da aynı ayarla sorar
PantrixTypeface(vararg fonts)Bir yazı tipi, font dosyalarıyla. PantrixTypeface.Default kütüphanenin kendi yazı tipidir
rememberPantrixTypography(typeface)Pantrix'in ölçeği, o yazı tipiyle. Yazı tipi değişirken eskisinde kalır, yenisi gelince bir kez değişir
rememberPantrixFontFamily(typeface)Yalnızca aile (FontFamily)
areTypefacesLoaded(vararg typefaces)Verilen yazı tiplerinin bütün fontları okundu mu. Sormak okumayı başlatır
isPantrixTypefaceLoaded()Aynısı, kütüphanenin kendi yazı tipi için
PantrixTypefaceGate(loaded, timeoutMillis, onShown, content)İçeriği yazı tipi okunduktan sonra çizer; en çok timeoutMillis kadar bekler

Android'de, iOS'ta ve masaüstünde hepsi hemen cevap verir: aynı kod her platformda yazılır, yalnızca web'de bekler.

3.1. Yazı tipini bir kez söyle

// commonMain
val Brand = PantrixTypeface(
    PantrixFont(Res.font.brand_regular),
    PantrixFont(Res.font.brand_medium, FontWeight.Medium),
    PantrixFont(Res.font.brand_bold, FontWeight.Bold)
)

@Composable
fun App() {
    PantrixTheme(typography = rememberPantrixTypography(Brand)) {
        // …
    }
}

Fontlar tek bir yerde sayılır; aile de, tarayıcıda okunup okunmadıkları da o listeden sorulur. İkisi ayrı yerlerde sayılırsa bir gün birbirini tutmaz (bkz. 5. bölüm, "Aynı anahtar").

Yalnızca kütüphanenin kendi yazı tipini kullanan bir uygulamanın bir şey söylemesi gerekmez: PantrixTheme { … } yeter.

3.2. Web girişi

// webMain
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    // Kutusu olmayan bir sayfada (tarayıcının sakladığı eski bir index.html) gövdeye kurulur.
    val box = "app".takeIf { document.getElementById(it) != null }
    ComposeViewport(viewportContainerId = box) {
        PantrixTypefaceGate(
            loaded = areTypefacesLoaded(Brand),
            onShown = { document.getElementById("loading")?.remove() }
        ) {
            App()
        }
    }
}

Kütüphanenin kendi yazı tipi için loaded verilmez: PantrixTypefaceGate(onShown = …) { App() }.

3.3. Sayfa

<head>
    <!-- Yazı tipi, uygulamanın kodu inerken indirilir. `crossorigin` olmadan tarayıcı bu indirmeyi
         uygulamanın kendi isteğine saymaz ve dosya iki kez iner. -->
    <link rel="preload" as="fetch" type="font/ttf" crossorigin
          href="composeResources/<paket>.generated.resources/font/brand_regular.ttf">
</head>
<body>
    <!-- Yükleme işareti: uygulamanın ilk karesi çizilene dek üstünde durur. -->
    <div id="loading">…</div>
    <!-- Uygulamanın kurulduğu kutu. -->
    <div id="app"></div>
    <script type="application/javascript" src="webApp.js"></script>
</body>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; }

#app { width: 100%; height: 100%; }

#loading {
    position: fixed;
    inset: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}

Adresteki <paket>, kaynakları taşıyan modülün üretilen Res sınıfının paketidir. Kütüphanenin kendi yazı tipinin adresi composeResources/com.pantrixui.foundation.generated.resources/font/plus_jakarta_sans.ttf'dir. Emin olmak için paketi bir kez derleyip build/dist/…/composeResources klasörüne bakın.

3.4. Çalışırken değişen yazı tipi

rememberPantrixTypography(typeface) bunu kendisi çözer: typeface değiştiğinde, yenisi okunana dek eski tipografiyi vermeyi sürdürür ve yenisini bir kez, tamamı geldiğinde verir.

val typeface = if (settings.brand) Brand else PantrixTypeface.Default
PantrixTheme(typography = rememberPantrixTypography(typeface)) { /* … */ }

Seçim ile değişim arasında dosyaların inmesi kadar bir süre geçer. O da istenmiyorsa yazı tipleri önden okunur: kapıya hepsi birlikte verilir ve sayfa hepsini önden indirir. Bu depodaki örnek uygulama öyle yapar, çünkü üç yazı tipini yan yana gösteren bir sayfası vardır:

PantrixTypefaceGate(loaded = areTypefacesLoaded(PantrixTypeface.Default, PlusJakartaSans, Poppins)) { App() }

Bedeli indirilen bayttır: örnek uygulamada on beş dosya, 2,0 MB. Bir ürün için doğru olan çoğunlukla yalnızca ilk ekranın yazı tipini önden okumak, ötekileri değişime bırakmaktır.

4. Pantrix olmadan

Aynı çözüm, yalnızca Compose Multiplatform'un kendi API'siyle. Aşağıdaki kod herhangi bir projeye kopyalanabilir; kütüphanedeki karşılıklarının sadeleştirilmiş hâlidir.

4.1. Ortak kod (commonMain)

/** Bir font: kaynaklardaki dosya ve ailenin onu kaydettiği ağırlık ile stil. */
data class AppFont(
    val resource: FontResource,
    val weight: FontWeight = FontWeight.Normal,
    val style: FontStyle = FontStyle.Normal
)

/** Fontların ailesi. Tarayıcıda henüz gelmemiş bir font, ailede yedek yazı tipidir. */
@Composable
fun rememberFontFamily(fonts: List<AppFont>): FontFamily {
    val read = key(fonts) { fonts.map { Font(it.resource, it.weight, it.style) } }
    return remember(read) { FontFamily(read) }
}

/** Fontların hepsi okundu mu. Sormak okumayı başlatır; web dışında her zaman doğrudur. */
@Composable
expect fun areFontsLoaded(fonts: List<AppFont>): Boolean

/** [value] okunmuşsa kendisi; okunmamışsa en son okunmuş olan. Hiçbiri okunmamışsa yine [value]. */
@Composable
fun <T> lastLoaded(value: T, loaded: Boolean): T {
    var last by remember { mutableStateOf<T?>(null) }
    if (!loaded) return last ?: value
    SideEffect { last = value }
    return value
}

/** [content]'i yazı tipi okunduktan sonra çizer; en çok [timeoutMillis] kadar bekler. */
@Composable
fun FontGate(
    loaded: Boolean,
    timeoutMillis: Long = 3000,
    onShown: () -> Unit = {},
    content: @Composable () -> Unit
) {
    var waiting by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(timeoutMillis)
        waiting = false
    }
    // Bir kez açılan kapı açık kalır: çizilmiş bir uygulama yeniden kurulmaz.
    var opened by remember { mutableStateOf(false) }
    if (opened || loaded || !waiting) {
        SideEffect { opened = true }
        content()
        val shown by rememberUpdatedState(onShown)
        LaunchedEffect(Unit) {
            // İçeriği taşıyan kareden sonraki ilk kare: o kare çizilmiştir.
            withFrameNanos { }
            shown()
        }
    }
}

4.2. Platform karşılıkları

// webMain
@OptIn(ExperimentalResourceApi::class)
@Composable
actual fun areFontsLoaded(fonts: List<AppFont>): Boolean {
    // Önce hepsi istenir, sonra bakılır: fontlar art arda değil, yan yana okunur.
    val read = key(fonts) { fonts.map { preloadFont(it.resource, it.weight, it.style).value } }
    return read.none { it == null }
}
// androidMain, iosMain, jvmMain
@Composable
actual fun areFontsLoaded(fonts: List<AppFont>): Boolean = true

preloadFont yalnızca web kaynak kümelerinde vardır ve @ExperimentalResourceApi taşır; ortak koddan çağrılamaz. expect/actual bu yüzden gerekir. webMain, js ve wasmJs hedeflerinin ikisi de tanımlıysa Kotlin'in kendiliğinden kurduğu kaynak kümesidir.

4.3. Kullanım

// commonMain
val Brand = listOf(
    AppFont(Res.font.brand_regular),
    AppFont(Res.font.brand_bold, FontWeight.Bold)
)

@Composable
fun App() {
    val family = rememberFontFamily(Brand)
    // Çalışırken değişen bir yazı tipi için: yenisi okunana dek eskisinde kalır.
    val shown = lastLoaded(family, loaded = areFontsLoaded(Brand))
    // typographyIn: tipografinizi verilen aileyle kuran kendi fonksiyonunuz.
    MaterialTheme(typography = typographyIn(shown)) { /* … */ }
}
// webMain
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    val box = "app".takeIf { document.getElementById(it) != null }
    ComposeViewport(viewportContainerId = box) {
        FontGate(
            loaded = areFontsLoaded(Brand),
            onShown = { document.getElementById("loading")?.remove() }
        ) {
            App()
        }
    }
}

Sayfa ve stil 3.3'tekilerle aynıdır.

5. Tuzaklar

TuzakNe olurNe yapılır
Aynı anahtarÖnden okunan font ile temanın istediği font aynı dosya, aynı ağırlık ve aynı stil değilse önbellekte bulunmaz; uygulama yine yedekle çizilirFontları tek bir listede say; aileyi de, soruyu da o listeden kur
&& ile sormakareLoaded(a) && areLoaded(b): ikincisi, birincisi gelene dek hiç sorulmaz; dosyalar art arda inerHepsini tek çağrıda ver
crossorigin'siz ön yüklemeTarayıcı indirdiğini uygulamanın fetch isteğine saymaz; dosya iki kez iner ve konsola uyarı düşeras="fetch" ve crossorigin birlikte
Gövdeye kurulan uygulamaComposeViewport, kurulduğu kutunun içini siler: yükleme işareti uygulamanın ilk karesinden önce gider, arada boş sayfa kalırUygulamayı kendi kutusuna kur; işareti ilk kareden sonra kaldır
Zaman aşımı olmayan kapıDosya gelmezse (yanlış adres, kesik ağ) sayfa sonsuza dek boş kalırBir süre bekle, sonra yedekle çiz
Kapanabilen kapıCevap sonradan "okunmadı"ya dönerse uygulama kaldırılır ve bütün durumunu yitirirBir kez açılan kapı açık kalsın
Eski index.htmlTarayıcı eski sayfayı saklamışsa kutu yoktur; kutuyu arayan uygulama hiç açılmazKutu yoksa gövdeye kur
Elle yazılan adreslerModülün ya da dosyanın adı değişince ön yükleme boşa iner; hiçbir şey bozulmaz, yalnızca geç inerAdresleri font klasörleriyle karşılaştıran bir test (bizde WebFontPreloadTest)
Gizli sekmeTarayıcı gizli sekmeye kare vermez; uygulama görünür olana dek kurulmazBir hata değildir; ölçerken sekmenin görünür olduğuna bak
Tarayıcı testleriKarma kaynakları sunmaz: testlerde font hiç gelmez ve soru hep "okunmadı" derTeste kendi okuyucunu ver (LocalResourceReader)
Her şeyi önden okumakHiç kullanılmayacak yazı tipleri de her ziyaretçiye inerYalnızca ilk ekranın yazı tipini önden oku; ötekileri değişime bırak
webMain'de dizijs ile wasmJs'in ortak kaynak kümesinde editör bir dizinin uzantısını çözemez: vararg bir parametrede flatMap, "Cannot access 'kotlin.Cloneable' which is a supertype of 'Array'" uyarısı verir. Derleme geçer, uyarı yalnızca editördedirvararg'ı ortak kodda listeye çevir; platform karşılığı liste alsın (4.1'deki areFontsLoaded gibi)

6. Doğrulama

Ölçüm betiği

Tarayıcıda bir karenin tam olarak ne zaman çizildiği dışarıdan görülmez; olayların sırası görülür. Üretim paketinin bir kopyasına, index.html'de uygulamanın betiğinden önce yüklenen küçük bir betik eklenir:

(function () {
  const log = (m) => console.log('[font-probe] ' + Math.round(performance.now()) + 'ms ' + m);
  const isFont = (s) => s.indexOf('/font/') >= 0;
  const origFetch = window.fetch;
  window.fetch = function (u) {
    const s = String((u && u.url) || u);
    if (isFont(s)) log('fetch ' + s.split('/').pop());
    return origFetch.apply(this, arguments);
  };
  // Bir kareden uzun süren kareler: baştan kurulan bir sayfa burada görünür.
  let last = performance.now();
  const tick = (now) => {
    if (now - last > 24) log('long frame ' + Math.round(now - last) + 'ms');
    last = now;
    if (now < 6000) requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
})();

Beklenen sıra: fontlar uygulamanın ilk uzun karesinden önce okunmuştur, ve o kareden sonra font için yeni bir istek görülmez. performance.getEntriesByType('resource') dosyayı kimin istediğini söyler: ön yükleme çalışıyorsa initiatorType "link"tir ve dosya bir kez iner.

Testler

TestNeye bakar
ui/foundation, PantrixTypefaceWebTest (tarayıcıda)Bir yazı tipi bütün fontları okununca okunmuştur; birlikte sorulanlar birlikte okunur; okunan, sonraki ilk kurulumda hazırdır; ölçek, yeni yazı tipi gelene dek eskisinde kalır
ui/foundation, PantrixTypefaceTestWeb dışında yazı tipi baştan okunmuştur; yolda olan verilmez, en son okunan verilir
ui/foundation, PantrixTypefaceGateTestKapı yazı tipi gelince açılır, bir kez açılınca açık kalır, sayfaya içerik çizildikten sonra haber verir, bir süre sonra beklemekten vazgeçer
app/shared, WebFontPreloadTestSayfanın önden indirdiği dosyalar, font klasörlerindeki dosyalarla aynıdır; her satır as="fetch" ve crossorigin taşır

7. Bu depoda nerede

DosyaNe
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/token/typography/PantrixTypeface.ktPantrixFont, PantrixTypeface, rememberPantrixFontFamily, areTypefacesLoaded, isPantrixTypefaceLoaded
ui/foundation/src/webMain/…/PantrixTypeface.web.ktareTypefacesLoaded'ın tarayıcıdaki parçası: areFontsLoaded (preloadFont). Yazı tiplerinin fontları ortak kodda tek listeye toplanır, burası yalnızca listeyi alır
ui/foundation/src/commonMain/…/PantrixTypography.ktrememberPantrixTypography(typeface): yeni yazı tipi gelene dek eskisinde kalır
ui/foundation/src/commonMain/…/PantrixTypefaceGate.ktKapı
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/theme/Typefaces.ktÖrnek uygulamanın kendi iki yazı tipi
app/webApp/src/webMain/kotlin/com/pantrixui/cmp/demo/main.ktWeb girişi: kutu, kapı, yükleme işaretinin kaldırılması
app/webApp/src/webMain/resources/index.html, styles.cssÖn yükleme satırları, #app ve #loading

8. Açık kalanlar

Bu işte görülen ve dokunulmayan maddeler docs/OPEN_ITEMS.md'dedir. Başlıcaları: