WhatsApp mühendislerinden, Wear OS uygulamalarını geniş ölçekte nasıl yükselttiklerini öğrenin. Bu oturumda, Material 3'ü entegre etme ve Wear widget'larını kullanma ile ilgili mimari ve kullanıcı deneyimi kararları ele alınmaktadır.
Ekibin uygulamalarını güncellemek ve tutarlı bir kullanıcı deneyimi sunmak için kullandığı dersler, kalıplar ve araçlar hakkında doğrudan bilgi edinin.
TransformingLazyColumn öğesini doğru şekilde kullanmak, dönüşümleri yalnızca bir düzeyde uygulamak ve Compose'un remember öğesiyle konumları önbelleğe almaktan kaçınmak anlamına gelir.Transkript
Herkese merhaba.
Adım Martin.
Ben de Subhag.
Meta'da Wear OS'te WhatsApp'ı geliştiren ekipte mühendis olarak çalışıyoruz.
Kısa süre önce uygulamamızı modernize ettik.
Bugün, ortak kod tabanına geçişten ve bunun üzerine yeni deneyimler oluşturmaktan öğrendiğimiz dersleri paylaşacağız.
Wear OS'te WhatsApp'ı ilginç kılan şey, telefonunuz olmadan çalışmasıdır.
Bağlandıktan sonra, kendi veritabanı ve sunucu bağlantısıyla tıpkı telefonunuz gibi uçtan uca şifrelenmiş kendi WhatsApp cihazı olarak çalışır.
Bu sayede, telefonunuz kapsama alanı dışında olduğunda bile kablosuz ağ veya LTE üzerinden çalışmaya devam eder.
WhatsApp'ı Wear OS'te ilk kez 2023'te kullanıma sunduk.
Bu sistem, Rust dilinde yazılmış kompakt ve taşınabilir bir istemci tarafından destekleniyordu.
Üretim aşamasına hızlıca geçmemizi sağladı.
Ancak bu, WhatsApp'ın ikinci ve bağımsız bir uygulamasıydı. Bu nedenle her protokol, şifreleme ve özellik iki kez oluşturulmak zorundaydı.
Bu nedenle saat uygulaması her zaman geriden geliyordu.
Taşınabilir olması, arka planı işlemeyi de zorlaştırdı.
İstemci; kendi iş parçacıklarını, zamanlayıcılarını ve soketlerini (Android'in sizin için yönetmek istediği şeyler) yönetiyordu.
Android'de arka planın kuralları vardır.
Uyku modundayken ağ bağlantınız kesilir.
İşleminiz önbelleğe alındıktan sonra yürütme süreniz sınırlı olur veya hiç olmaz.
Bunun üstesinden gelmek için bir hizmete, işe veya desteğe ihtiyacınız vardır.
Bu nedenle, saat uygulamamızın bunları manuel olarak bağlaması gerekiyordu.
Ancak Android kod tabanımız bunu zaten yapıyor.
Bu nedenle, milyarlarca cihazda test edilmiş ve Android kurallarına uyan Android kod tabanımızı yeniden kullanmaya ve bu kod tabanına geçmeye karar verdik.
Böylece yeni özelliklerin iki kez oluşturulması gerekmez.
Hedef basitti: İş mantığını korumak ve kullanıcı arayüzünü kaldırmak.
Geriye, API olarak Kotlin akışlarını kullanan başsız bir kitaplık kalır.
Ancak bu boyuttaki bir uygulamayı tek seferde çözemezsiniz.
Bazı modüllerde kullanıcı arayüzü ile altyapı bir arada bulunur.
Diğerleri, Wear OS uygulamasında ihtiyaç duymadığınız özelliklere bağlıdır.
Bu nedenle iki koldan çalıştık: Modülerleştirmeye devam ettik ve henüz kaldıramadığımız kısımları çıkardık veya üzerine yazdık.
Modülerleştirmeyle başlayalım.
Kodu derlemenin dışında tutmak için basit bir "bu telefon uygulamasıysa" ifadesi işe yaramaz çünkü kodu kaldırmaz.
Bu nedenle, özellikle isteğe bağlı ve çoklu bağlama olmak üzere DI'ya büyük ölçüde güveniyoruz.
Şirket içi DI kullanıyoruz ancak bu, Dagger'ın sunduklarıyla yakından eşleşiyor.
Bu nedenle örnekler Dagger'da verilmiştir.
İsteğe bağlı bağlama (Dagger'da BindsOptionalOf) ile bir bağımlılığı isteğe bağlı olarak tanımlayabilirsiniz (ör. arka ucu çağırma).
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}Ardından, saat derlemesi uygulamayı sağlayan bir modül içeriyorsa eklenen arka uç mevcut olur ve arama çalışır.
Modülü atlarsanız boş olur ve API'niz NotAllowed döndürebilir.
Çoklu bağlama da aynı şekilde çalışır ancak kümeler için kullanılır.
@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)Multibinds ile bir küme tanımlayabilirsiniz (ör. MessageHandler). Ardından her özellik modülü, küme çoklu bağlamasını kullanarak kendi işleyicisini ekleyebilir.
Ödemeleri desteklemek istemiyorsanız örneğin, ödeme modülünün tamamını derlemenin dışında bırakabilirsiniz. Bu durumda, eklenen küme bu işleyiciyi içermez.
Bu sayede, özelliğin desteklenip desteklenmediği derleme grafiği için bir soru olur.
Ancak bu ayrıştırma iki ucu keskin bir kılıç gibidir.
Doğrudan referanslar kullanıldığında bir modülün silinmesi derlemenizi bozar.
İsteğe bağlı veya çoklu bağlama kullanıldığında modüle referans veren bir şey yoktur. Bu nedenle derleme başarılı olur ve özellik sessizce kaybolur ya da varsayılan özellik kullanılır.
Biz de tam olarak bunu yaptık.
Yeniden düzenleme işlemi, bir özelliği kendi modülüne taşıdı ve derleme uygulamamız ile saat uygulamamız bu özelliği içermedi.
Bu sorun nedeniyle sesli mesajların süresi sıfır saniye olarak gösteriliyordu.
Peki bunun yaşanmamasını nasıl sağlayabilirsiniz?
Kontrolü tekrar devreye sokmak için bir test yapmanız gerekir.
Bizim durumumuzda, önemli arayüzler için telefon uygulaması ile saat uygulaması arasındaki çoklu bağlama kümelerini karşılaştırıyoruz.
Bu sayede, her zaman doğru uygulamanın bağlı olduğundan emin oluruz.
İkinci bölümde ise henüz kaldıramadığımız içeriklerden bahsedeceğiz.
Modülleri yeniden kullanmak işinizi kolaylaştırır ancak beyan ettikleri her şey manifestinizle birleştirilir.
Telefon başlatıcı etkinliği, ikincil bir uygulama simgesi ekler veya gereksiz içerik sağlayıcılar uygulamanızla birlikte başlatılır.
Manifest birleştirme, bileşenleri çıkarmanıza olanak tanıyan kullanışlı bir tools:node="remove" özelliği sunar.
<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" />Bu işlem tamamlandıktan sonra küçültücü, kodu da kaldırabilir.
Etkinliğe ulaşması gereken başka bir şey varsa yeni sınıfa yönlendirmek için activity-alias kullanabilirsiniz.
Ancak dikkat edilmesi gereken nokta, bu özelliklerin yalnızca daha düşük öncelikli manifestler için geçerli olmasıdır. Bu nedenle, bir öğenin kaldırılıp kaldırılmayacağına birleştirme sırası karar verir.
Kaynaklar da aynı şekilde çalışır.
Yeniden kullanım, telefon dizelerini ücretsiz olarak sunar.
Ancak bazıları, bir saatin yapamayacağı şeyleri vaat eder.
Her sohbetin başında yer alan şifreleme uyarısıyla ilgili bir örneği inceleyelim.
Telefonda, "daha fazla bilgi" bağlantısıyla biter.
Ancak saatte bağlantının arkasında hiçbir şey yok.
Bu nedenle, aynı anahtarı saatteki daha yüksek öncelikli modülde yeniden tanımlar ve dizeyi "Daha fazla bilgi edinmek için telefonunuzu kullanın" ile değiştiririz.
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>Ardından kaynak birleştirici bizimkini seçer.
Ayrıca dizeyi yanlışlıkla geçersiz kılmamanızı sağlayacak bir şeye de ihtiyacınız vardır.
Bu nedenle, geçersiz kılma işlemi izin verilenler listesinde değilse başarısız olan bir derleme kuralı oluşturduk.
Şimdi de bildirimlerden bahsedelim.
Wear OS, varsayılan olarak telefon bildirimlerinizi köprüler veya yansıtır.
Bu harika bir özellik ancak yalnızca telefonunuz bağlıyken çalışır.
WhatsApp'ın bağımsız olarak çalışmasını istiyoruz. Bu nedenle, uygulamamız kendi bildirimlerimizi yayınlıyor.
Ancak köprüleme açık kalırsa her mesaj iki kez titreşir.
Köprü oluşturmayı devre dışı bırakmak yinelenenleri düzeltir.
Statik bir Manifest işareti vardır ancak kullanıcı giriş yapmadan önce yükleme işleminden itibaren geçerli olur.
Yani uygulamayı yükleyip giriş yapmazsanız tüm bildirimleri kaybedersiniz. Bu da pek iyi bir şey değil, değil mi?
Bu nedenle, çalışma zamanında BridgingManager kullanarak ayarlıyoruz.
isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}Giriş yaptığınızda köprü oluşturma özelliğini devre dışı bıraktık.
Çıkış yaptığınızda bu ayarı tekrar etkinleştiririz.
Ancak değerleri geri okuyacak bir API olmadığını ve akıllı saatin bu değerleri kaybettiğini gözlemlediğimizi unutmayın.
Bu nedenle, saat şarj cihazından çıkarıldığında veya uygulama güncellendiğinde verileri tekrar yazarız.
Son olarak, çıkış yapma konusundan bahsetmek istiyorum.
Kullanıcılar, bir kol saatinin hesabınızla bağlantısını kaldırdığınızda mesajların da kaldırılmasını bekler.
Bu nedenle mesajlar, anahtarlar ve ayarlar dahil her şeyi siliyoruz.
Peki bunu güvenli bir şekilde nasıl yapabilirsiniz?
Arka plan iş parçacıkları hâlâ dosyaya yazarken dosyaları silerseniz SQLite istisnaları alırsınız veya daha da kötüsü, yeni oluşturulan veritabanlarına yazma işlemi gerçekleşir.
Bir işlemi durdurmadan durumunu temizleyemezsiniz.
Android, verileri temizleyip uygulamayı durduran ancak izinleri de sıfırlayan clearApplicationUserData API'sini sunar.
Bazı OEM'lerde bildirim köprüleme, bildirim izinlerine bağlıdır. Bu nedenle, bu özelliği kullanırsanız telefon yansıtma özelliğini de aniden kaybedersiniz.
Bu nedenle süreci kendimiz durdururuz.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()İkincil bir işlemde küçük bir hizmet oluştururuz. Bu işlemde DI ve WorkManager bulunmadığından uygulamanın geri kalanı başlatılmaz.
Çıkış yaptıktan sonra bu hizmet, uygulamamızdaki diğer tüm işlemleri sonlandırır ve yalnızca hiçbir işlem çalışmıyorken tüm verileri siler.
clearApplicationUserData kullanmadığımız için izinler korunur ve bildirim köprülemeyi açabiliriz. Böylece telefon bildirimleri geri gelir.
İlk bölüm bu kadardı. Şimdi kullanıcı arayüzü yenilememizden bahsetmesi için sözü Subhag'a bırakıyorum.
Teşekkürler Martin.
Paylaşılan altyapı sayesinde WhatsApp'ı bileğinizde gerçekten evinizdeymiş gibi hissettirmeye odaklandık.
Bu yolculuğun ilk adımı Material 3'e geçişti. Ancak önce nedenini soralım.
Her pikselin önemli olduğu yuvarlak bir ekranda Material 3, WhatsApp'ı bileğe uygun hale getirmek için doğru bileşenleri ve tasarım temelini sağladı.
Görsellere bakarsanız ilk bakışta yalnızca bir kullanıcı arayüzü güncellemesi gibi görünebilir.
Material 2'den 3'e geçiş yapıyorduk ve bu bir gecede olamazdı.
Eski ve yeni kodun bir anahtarın arkasında yan yana bulunması gerekiyordu.
Bu nedenle, Figma ile görsel olarak eşleşen ekranlarda bazı bozuk davranışlar görülmeye devam etti.
Örneğin, ayar düğmesini kaydırma, geri gitmek için sağa kaydırma gibi en temel hareketlerden bazıları çalışmıyordu.
Yani tasarım sistemi geçişi yalnızca görsel bir geçiş değil, aynı zamanda davranışsal bir geçiş de demektir.
Davranışlardan bahsetmişken, kenarlara yakın sıralara bakarsanız bunların daha dar olduğunu ve eğriye sıkıştırıldığını görürsünüz.
Bu, TransformingLazyColumn'dur.
Satırlar, dairesel çerçeve tarafından kesilmeyecek şekilde dönüştürülür.
Dinamik ekranlar işleri zorlaştırabilir.
Yeni mesajlar gelir, satırlar hareket eder ve her şekilden baloncuklar ortaya çıkar.
Doğru yapmanın ne anlama geldiğine bakalım.
Sol tarafta gördüğünüz gibi, baloncuklar çok hızlı bir şekilde küçülüyor.
Her mesaj, dahili olarak bir kartın içine yerleştirilmiş bir balondur.
Üst karta bir dönüşüm uygulamasını söyledik ancak aynı dönüşüm alt balona da aktarılıyordu.
Sonuç olarak, alt baloncuk amaçlanan dönüşüm miktarının iki katını alır.
Çözüm, dönüşümü tek bir düzeyde (üst kartta) uygulamaktı.
Bu da önemli bir ilkedir.
Bir öğe için bir dönüşüm sağladık.
Başka bir sorun daha var: Ekranın tam ortasında tam boyutlu olması gereken baloncuklar küçük ve soluk görünüyor.
Boyut, ekrandaki konumlarına göre değişir.
İlk sürümümüzde, boyut sığdırmayı Compose'un remember işlevini kullanarak kaydettik ancak bu işlem, eski konumunu da kaydetti.
Sonuç olarak, yeni bir mesaj geldiğinde ve eski baloncukları yukarı kaydırdığında, dönüşümleri hâlâ eski konumlarına göre yapılır.
Bu da hataya neden olur.
Bu sorunu düzeltmek için, boyutu kaydetmek üzere remember çağrısını kullanmayı bıraktık ve çağrıyı kaldırdık.
Bu da bir öğrenme sürecidir.
Bir satır taşındığında dönüşümü de onunla birlikte taşınmalıdır.
Burada, koddaki her iki düzeltmeyi de görebilirsiniz.
// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}Sol üstte, dönüşümün iki düzeyde uygulandığını, sağ üstte ise yalnızca bir düzeyde (M3 kartı) uygulandığını görebilirsiniz.
Sol altta, dönüşümü önbelleğe almak için remember çağrısını kullanıyoruz ancak bu çağrı, konumu da önbelleğe alıyor.
Sağ altta ise her seferinde baloncukların son konumunu dikkate alarak dönüşümü hesaplıyoruz.
Gördüğünüz gibi, tüm ekranlarda tutarlı bir deneyim elde ediliyor. Önemli içerikler merkeze yakın kalırken satırlar doğal bir şekilde kenara doğru hareket ediyor.
Şimdi de bildirimlerden bahsedelim.
Şimdi bileğinizin titrediğini, saatinize baktığınızı ve bir mesaj gördüğünüzü hayal edin.
Ancak birkaç dakika sonra bileğiniz tekrar tekrar titrer.
Birden fazla bildirim alırsınız ve her kontrol ettiğinizde aynı şeyi görürsünüz.
Bu gerçekten can sıkıcı bir durum.
Bu sorunu çözmek için kendimize iki soru sorduk.
Kullanıcıya hangi etkinlik için bildirim gönderilmeli?
Bildirimleri sessizce güncellemesi gereken etkinlik hangisidir?
Bu hata, arka planda gerçekleşen ve bildirimi sessizce güncellemesi gereken profil fotoğrafı güncellemeleri, medya indirmeleri ve makbuzların kullanıcıyı uyarmasından kaynaklanıyordu.
Artık yeni bir mesaj geldiğinde bileklik tam olarak bir kez titrer, uyarı gerektirmeyen etkinlikler ise bildirimi sessizce yeniler.
Bu da bir ders.
Bildirimler, temel kullanıcı yolculuklarınızdan biriyse her uyarının kullanıcının dikkatini çekmeye değer olduğundan emin olmalıyız.
Bildirimler konusunda.
Daha önce WhatsApp Wear OS bildirimleri iki işlem yapmanıza olanak tanıyordu.
Mesajı okuyabilir veya uygulamayı açabilirsiniz.
Daha önce bir bildirimi doğrudan yanıtlayamıyordunuz.
Bu nedenle, RemoteInput API ile Android'in bildirim işlemini kullanarak bu boşluğu doldurduk.
Kullanıcı artık yanıtı yazabilir, söyleyebilir veya hızlı yanıtlardan birine dokunabilir.
Ancak unutmayın, e-postayı göndermekle işiniz bitmez.
Mesaj başarıyla gönderilirse sohbeti okundu olarak işaretler ve bildirimi temizleriz.
Mesaj teslim edilemezse tek dokunuşla yeniden deneme seçeneği sunarız.
Bu özelliği geliştirirken öğrendiğimiz bir diğer önemli nokta da bildirim işleminin eksiksiz bir uçtan uca iş akışı olarak ele alınması gerektiğiydi.
Sırada kartlar ve widget'lar var.
Kart, gördüğünüz gibi tam ekran bir görünümdür. Kadranınızı kaydırarak karta ulaşabilirsiniz.
Widget'lar ise kaydırılabilir tek bir ekrana sığabilen birden fazla küçük karttan oluşur.
Hem WhatsApp Kutuları hem de Widget'lar aynı temel verilerle desteklenir.
Bu da bir sürpriz.
Bu kullanıcı arayüzünün uygulamamız tarafından çizildiğini düşünebilirsiniz ancak aslında durum böyle değildir.
Biz yalnızca kullanıcı arayüzünü ve verileri tanımlarız, sistem de bunları çizer.
Ayrıca, simgelerden herhangi birine dokunulduğunda uygulamanızın kodu çalıştırılamaz.
Yalnızca bir ekranı açmasını isteyebilirsiniz.
Dersimizin sonuna geldik.
Sistem, kullanıcı arayüzünüzü çiziyorsa bu, ürününüzün ve test metriklerinizin bir parçasıdır.
Frank ve Yuri, widget'lar hakkındaki konuşmalarında daha ayrıntılı bilgi verecek.
Bu da beni son slayta getiriyor. Bir adım geri çekilip kendimize soralım: Günün sonunda, uygulamalarımızı daha fazla kullanıcının kullanmasını istiyoruz.
Şimdi bir geliştirici olarak bir gün kontrol panellerinize giriş yaptığınızı ve etkin kullanıcı sayısında önemli bir düşüş olduğunu gördüğünüzü düşünün.
Öncelikle bu durum çok endişe verici olurdu.
Ardından hata ayıklamaya başlayabilirsiniz.
Bu durumda, bir kilitlenme olduğunu veya kötü bir sürüm yayınlandığını düşünebilirsiniz.
Ancak bizim için bunların hiçbiri geçerli değildi.
Her WhatsApp uygulama sürümünün 90 günlük yerleşik bir geçerlilik süresi olduğu anlaşılıyor.
Kullanıcıların eski bir derlemede takılı kaldığını ve yeni derlemelerin saatlerine ulaşmasının zaman aldığını düşünün.
Sonuç olarak, kullanıcılar uygulamadan ayrılmıyor ancak uygulamaları çalışmayı durduruyor.
Bu nedenle, uygulamayı güncelleme işlemini ürünün bir parçası haline getirdik.
Ekranda gördüğünüz gibi, uygulamanın süresinin dolmasına 15 gün kala bu uyarı gösterilir.
Ardından 7, 5, 3, 2 ve 1 gün kaldığında tekrar gösterilir.
Kullanıcı, bildirimi kapatabilir veya uygulamasını güncellemek için Play Store'a gidebilir.
Bu sayede kullanıcılar, uygulamalarının kullanım süresinin dolmak üzere olduğunu hatırlatan birden fazla bildirim alır ve uygulamalarını güncellemek için birden fazla fırsat elde eder.
Bu da son dersimiz.
Geliştiriciler olarak işimiz, bir güncellemeyi %100 oranında kullanıma sunduğumuzda bitmez.
Bu süreç, ancak kullanıcılar güncellemelerimizi aldığında tamamlanır.
Böylece sona geldik.
Sohbete devam etmek isterseniz ofis saatlerinde uğrayıp merhaba diyebilirsiniz.
Sırada Wear widget'ları hakkında konuşmak üzere Frank ve Yuri var.
Teşekkür ederiz.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}@Module
interface CoreModule {
@Multibinds
fun handlers(): Set<MessageHandler>
}
@Module
interface ReactionsModule {
@Binds @IntoSet
fun reactions(impl: ReactionHandler):
MessageHandler
}
class MessageDispatcher @Inject constructor(
private val handlers: Set<MessageHandler>,
)<!-- remove unused Main activity node -->
<activity android:name="com.whatsapp.Main"
tools:node="remove" />
<!-- redirect any calls to a new Activity -->
<activity-alias
android:name="com.whatsapp.Main"
android:targetActivity=".MyNewActivity" />
<provider
android:name="…MigrationContentProvider"
tools:node="remove" /><!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>isRegistered.collect {
when (it) {
// watch posts its own
true -> bridging.setEnabled(false)
// phone bridges again
false -> bridging.setEnabled(true)
}
}
fun setEnabled(bridgingEnabled){
bridgingManager.setConfig(
BridgingConfig.Builder(context,
bridgingEnabled)
.build())
}clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()// FIX #1: ONE OWNER
CompositionLocalProvider(
LocalSurfaceTransformation provides null
) {
M3Card(transformation = t) {
SharedChatEntry(...)
}
}
// FIX #2: CURRENT ITEM SCOPE
val t = SurfaceTransformation(spec)// BUG #1: DOUBLE MORPH
M3Card(transformation = t) {
CompositionLocalProvider(
LocalSurfaceTransformation provides t
) { SharedChatEntry(...) }
}
// BUG #2: STALE ITEM SCOPE
val t = remember(spec) {
SurfaceTransformation(spec)
}