Özgür Işık Damar
8 dk okuma

React Native, native driver ve nihayet hissedebildiğin jank

useNativeDriver sana ne zaman gerçekten bir şey kazandırır, ne zaman yalan söyler ve barkod ekranını yavaş hissettiren o düşen frame'i nasıl bulursun.

react-nativeperformancemobile

Depo uygulamamızda vardiya başına yaklaşık kırk bin tarama yapan bir barkod ekranı var. Ürün ekibinden gelen geri bildirim hep aynıydı: "fena değil, ama tarama sonrası açılan modal biraz yavaş hissettiriyor." İki ay boyunca bunu "Android animasyonu işte Android animasyonudur" diye geçtim.

Sonra bir depo çalışanının yanına oturdum ve onun arka arkaya 200 paket taramasını izledim. Sonunda yavaş diyen ben oldum. Jank gerçekti. Üstüne, dev cycle'da görmediğin türden bir jank'tı — çünkü masanda arka arkaya 200 şey taramazsın.

İşte useNativeDriver, bridge ve frame'i gerçekten ne hareket ettirir hakkında öğrendiklerim.

useNativeDriver ne yapar, kısaca

React Native'de animasyon klasik olarak JS bridge üzerinden çalışır. Her frame'de JS yeni değeri hesaplar, bridge üzerinden native tarafa gönderir, native taraf view'ı günceller. Frame başına round-trip, 60 FPS'de. Bridge çoğunlukla iyi — değil olana kadar.

useNativeDriver: true bunu çevirir. JS, animasyon tanımını bir kez native tarafa verir; native taraf interpolasyonu tamamen UI thread'de çalıştırır. JS meşgul olabilir, bloklanmış olabilir, alev almış olabilir — animasyon yine de 60 FPS'i tutturur.

import { Animated } from "react-native";
 
const opacity = useRef(new Animated.Value(0)).current;
 
useEffect(() => {
  Animated.timing(opacity, {
    toValue: 1,
    duration: 200,
    useNativeDriver: true, // <-- önemli olan flag
  }).start();
}, []);

İncesi: native driver yalnızca property'lerin bir alt kümesini destekler. Transform, opacity, scroll pozisyonu — evet. Width, height, background color, layout tetikleyen her şey — hayır. Width'i useNativeDriver: true ile animate etmeye çalışırsan runtime uyarısı alırsın ve animasyon sessizce JS driver'ına düşer.

Ben ne shipliyordum (yanlış)

Tarama sonucu modal'ı aşağıdan yukarı kayıp fade-in yapıyordu. Orijinal kod:

Animated.parallel([
  Animated.timing(translateY, {
    toValue: 0,
    duration: 250,
    useNativeDriver: true,
  }),
  Animated.timing(modalHeight, {
    toValue: 320,
    duration: 250,
    useNativeDriver: false, // height native-drive edilemiyor
  }),
]).start();

İyi gibi görünüyor. Animate oluyor. Translate UI thread'de, height JS thread'de — aynı anda bitiyorlar. Sorun ne?

Sorun şu: o 250ms boyunca her scan callback'i, her Redux dispatch'i, her barkod handler tick'i, height interpolasyonu yapan aynı JS thread için kavga ediyor. Ekran frame düşürüyor. Sonraki scan handler'ı geç çalışıyor. Kullanıcı animasyon sonrasında lag görüyor, sırasında değil.

Bu, arka arkaya 200 şey taramazsan Flipper'da görünmeyen jank.

Asıl düzelten şey

Sırayla iki şey:

1. Layout animate etmeyi bırak

Modal'ın büyümesi gerekmiyordu. Tam boyutta belirip yukarı kayması gerekiyordu. O yüzden height animasyonunu, ekranın altından gelen bir transform ile değiştirdim:

const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
const opacity = useRef(new Animated.Value(0)).current;
 
useEffect(() => {
  Animated.parallel([
    Animated.timing(translateY, {
      toValue: SCREEN_HEIGHT - 320,
      duration: 220,
      useNativeDriver: true,
    }),
    Animated.timing(opacity, {
      toValue: 1,
      duration: 220,
      useNativeDriver: true,
    }),
  ]).start();
}, []);

Aynı görsel sonuç. Animasyon sırasında sıfır JS-thread işi. Scan handler zamanında çalışıyor. Sıradaki barkod lag'siz iniyor.

2. Driver'ın gerçekten native çalıştığını doğrula

Footgun şu: React Native, useNativeDriver: true sessizce düştüğünde sana yüksek sesle söylemez. Dev konsoluna bir uyarı düşürür, sonra JS driver ile devam eder. Production build'lerde uyarı yok.

Mount'ta tetiklenen ve animasyon yalan söylüyorsa testi düşüren küçük bir dev-only assertion ekledim:

function assertNativeDriverWorks(value: Animated.Value, name: string) {
  if (__DEV__) {
    // Internal API — RN sürümleri arasında kırılabileceğini kabul et.
    const isNative = (value as unknown as {
      _animation: { _useNativeDriver: boolean } | null;
    })._animation?._useNativeDriver;
 
    if (isNative === false) {
      // eslint-disable-next-line no-console
      console.error(
        `[anim] ${name} native driver bekliyordu, JS'e düştü`,
      );
    }
  }
}

Evet, private alanlara dokunuyor. Evet, RN upgrade'lerde kırılabilir. O yüzden __DEV__ ile sarılı ve runtime kontrolü değil smoke test olarak kullanılıyor.

Beni şaşırtan şey

useNativeDriver: true her zaman JS driver'dan hızlı değildir. Sakin bir ekrandaki çok kısa animasyonlarda JS driver iyidir ve animasyonu native'e devretmenin köprüleme maliyeti cold-start metriklerinde görünebilir. Native driver sana yük altında stabil 60 FPS satar. Ekranında başka iş yoksa iki driver da aynı görünür.

Kazanç tam olarak JS thread meşgulken. Bizim durumda barkod taraması fırtınası sırasında.

Trade-off'lar

Her zaman native-drive edemezsin. Tasarımın height, width, padding, layout tetikleyen bir şey kullanıyorsa seçeneklerin var:

  • Animasyonu transform + opacity kullanacak şekilde yeniden tasarla (neredeyse her zaman mümkün)
  • JS-driver maliyetini kabul et (ekranın sakinse problem değil)
  • Reanimated 3'e geç — shared value ve worklet'ler tüm bu alt küme problemini bypass eder

Jest'li hareketler için Reanimated kullanıyoruz. Diğer her şey için built-in AnimateduseNativeDriver: true ile kullanıyoruz, çünkü üç satır kod ve çalışıyor.

Yük altında bir ekranın yavaşladığını hissedene kadar bunlara ihtiyacın yok. Hissettiğin gün, çözüm bu yazıdaki en basit şey: layout animate etmeyi bırak.

Özet

Native driver sihirli bir flag değil. Şunu söyleyen bir sözleşmedir: "Söz veriyorum, bu animasyon yalnızca transform ve opacity'ye dokunuyor — UI thread yardımsız çalıştırabilir." Sözleşmeye uy; JS ne kadar meşgul olursa olsun ekran akıcı kalır. Sessizce boz; on tarama için iyi, iki yüz tarama için yavaş hisseden bir scanner ekranı shipliyorsun.

Bridge yavaş değil. Bridge, başka yaptıklarınla meşgul. İşi bridge'den kaldır.

// madem buradasın