מהנדסים של וואטסאפ מסבירים איך הם שדרגו את האפליקציה ל-Wear OS בקנה מידה נרחב. בסשן הזה נדבר על ההחלטות שקשורות לארכיטקטורה ולחוויית המשתמש (UX) בשילוב של Material 3 ובהטמעה של ווידג'טים ל-Wear.
כאן תוכלו לקבל תובנות ישירות לגבי הלקחים, הדפוסים והכלים שבהם הצוות השתמש כדי לעדכן את האפליקציה ולספק חוויית משתמש עקבית.
TransformingLazyColumn בצורה נכונה, צריך להחיל טרנספורמציות רק ברמה אחת ולהימנע משמירת מיקומים במטמון באמצעות remember של Compose.תמליל
שלום לכולם,
קוראים לי מרטין.
ואני סובהאג.
אנחנו מהנדסים ב-Meta בצוות שאחראי על וואטסאפ ב-Wear OS.
לאחרונה גם עדכנו את האפליקציה שלנו.
היום נשתף את הלקחים שהפקנו מהמעבר לבסיס קוד משותף ומהפיתוח של חוויות חדשות על גביו.
מה שמעניין בוואטסאפ ב-Wear OS הוא שהאפליקציה פועלת בלי הטלפון.
אחרי הקישור, המכשיר פועל כמכשיר וואטסאפ נפרד, עם הצפנה מקצה לקצה כמו בטלפון, עם מסד נתונים משלו וחיבור לשרת.
הוא ממשיך לפעול גם ב-Wi-Fi או ב-LTE כשהטלפון מחוץ לטווח.
השקנו את וואטסאפ ב-Wear OS בשנת 2023.
הוא הופעל על ידי לקוח קומפקטי ונייד שנכתב ב-Rust.
הוא עזר לנו להגיע לייצור במהירות.
עם זאת, זו הייתה הטמעה שנייה ועצמאית של וואטסאפ, כלומר היה צריך לבנות כל פרוטוקול, הצפנה ותכונה פעמיים.
לכן, אפליקציית השעון החכם תמיד הייתה צריכה להשלים פערים.
בגלל שהמכשיר נייד, גם הטיפול ברקע היה מסובך.
הלקוח טיפל בשרשורים, בטיימרים ובשקעים שלו, דברים ש-Android רוצה לנהל בשבילכם.
ב-Android, יש כללים לגבי הרקע.
במצב Doze, החיבור לרשת מתנתק.
אחרי שהתהליך נשמר במטמון, זמן הביצוע מוגבל או לא קיים.
כדי להתגבר על זה, צריך שירות, משימה או התראה.
לכן, באפליקציית השעון החכם שלנו היינו צריכים לחבר את זה ידנית.
אבל בסיס הקוד של Android כבר עושה את זה.
לכן החלטנו להעביר את בסיס הקוד של Android ולהשתמש בו שוב. בסיס הקוד הזה נבדק במיליארדי מכשירים והוא כבר פועל לפי הכללים של Android.
כך לא צריך לפתח תכונות חדשות פעמיים.
המטרה הייתה פשוטה: לשמור על הלוגיקה העסקית ולהסיר את ממשק המשתמש.
מה שנשאר זו ספרייה ללא ממשק משתמש שמשתמשת בתהליכי Kotlin בתור ה-API שלה.
האתגר הוא שאי אפשר לפרק אפליקציה בגודל כזה בבת אחת.
בחלק מהמודולים יש שילוב של ממשק משתמש ותשתית.
אחרות תלויות בתכונות שלא צריך באפליקציה ל-Wear OS.
אז עבדנו בשני מסלולים: המשכנו להפוך את הקוד למודולרי, והסרנו או החלפנו את מה שלא יכולנו להסיר עדיין.
נתחיל עם מודולריזציה.
כדי למנוע הכללה של קוד בגרסה, הצהרה פשוטה כמו "אם זו אפליקציית טלפון" לא תעבוד כי היא לא מסירה את הקוד.
לכן אנחנו מסתמכים מאוד על הזרקת תלויות, במיוחד על הזרקת תלויות אופציונלית ועל הזרקת תלויות מרובות.
אנחנו משתמשים ב-DI פנימי, אבל הוא דומה מאוד למה ש-Dagger מציע.
לכן הדוגמאות הן ב-Dagger.
באמצעות קישור אופציונלי, ב-Dagger זה BindsOptionalOf, אתם יכולים להצהיר על יחסי תלות כאופציונליים, למשל קריאה לקצה העורפי.
@Module
interface CallingModule {
@BindsOptionalOf
fun callingBackend(): CallingBackend
}
class Calls @Inject constructor(
val backend: Optional<CallingBackend>,
) {
fun start(): CallResult =
backend.getOrNull()?.start()
?: CallResult.NotAllowed
}אם ה-build של השעון כולל מודול שמספק את ההטמעה, הקצה העורפי שהוזרק יהיה קיים והקריאה תפעל.
אם משמיטים את המודול, הוא יהיה null, וה-API יכול להחזיר 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>,
)אפשר להצהיר על קבוצה באמצעות multibinds, לדוגמה MessageHandler, ואז כל מודול של תכונות יכול להוסיף handler משלו באמצעות multibinding לקבוצה.
אם אתם לא רוצים לתמוך בתשלומים, אתם יכולים להשאיר את כל מודול התשלומים מחוץ ל-build, והקבוצה שנוספה לא תכלול את ה-handler הזה.
כך, השאלה אם התכונה נתמכת או לא היא שאלה של תרשים ה-build.
עם זאת, ההפרדה הזו פועלת בשני הכיוונים.
אם משתמשים בהפניות ישירות, מחיקה של מודול תגרום לכשל ב-build.
עם קישור אופציונלי או קישור מרובה, אין שום דבר שמפנה למודול, כך שה-build עובר, והתכונה נעלמת בשקט, או שברירת המחדל מתרחשת.
וזה בדיוק מה שקרה.
במהלך שכתוב קוד, תכונה מסוימת הועברה למודול משלה, והאפליקציה שלנו לבניית אפליקציות והאפליקציה שלנו לשעונים חכמים לא כללו אותה.
הבעיה גרמה לכך שהודעות קוליות הוצגו עם משך זמן של אפס שניות.
איך אפשר למנוע את זה?
אז צריך בדיקה כדי להחזיר את הסימון.
במקרה שלנו, אנחנו משווים בין קבוצות של קישורים מרובים בין אפליקציית הטלפון לאפליקציית השעון החכם בממשקים הרלוונטיים.
כך אנחנו מוודאים שההטמעה הנכונה תמיד מקושרת.
המסלול השני עוסק במה שעדיין לא הצלחנו להסיר.
שימוש חוזר במודולים יחסוך לכם עבודה, אבל כל מה שהם מצהירים עליו יתמזג במניפסט שלכם.
פעילות של מרכז האפליקציות בטלפון תוסיף סמל אפליקציה משני, או שספקים מיותרים לניהול גישה יתחילו לפעול עם האפליקציה.
הכלי למיזוג מניפסטים מציע מאפיין tools:node="remove" שימושי שמאפשר להוציא את הרכיבים.
<!-- 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" />ואחרי שהם יוסרו, גם המכווץ יוכל להסיר את הקוד.
אם עדיין צריך להגיע לפעילות, אפשר להשתמש ב-activity-alias כדי להפנות לכיתה חדשה.
עם זאת, חשוב לשים לב שהמאפיינים האלה חלים רק על קובצי מניפסט בעדיפות נמוכה יותר, כך שסדר המיזוג קובע אם משהו יוסר או לא.
המשאבים פועלים באותו אופן.
שימוש חוזר מאפשר לכם לקבל מחרוזות לטלפון בחינם.
עם זאת, חלק מהאפליקציות מבטיחות דברים ששעון לא יכול לעשות.
בואו נראה דוגמה בסוף ההודעה על ההצפנה שמופיעה בתחילת כל צ'אט.
בטלפון, בסוף ההודעה יש קישור למידע נוסף.
אבל אין שום דבר מאחורי הקישור בשעון.
לכן, אנחנו מצהירים מחדש על אותו מפתח במודול בעדיפות גבוהה יותר בשעון ומחליפים את המחרוזת ב "כדי לקבל מידע נוסף, צריך להשתמש בטלפון".
<!-- phone -->
<string name="…_disclosure">… Learn more</string>
<!-- wearable override, same key -->
<string name="…_disclosure">…Use your phone to learn more.</string>ואז המיזוג של המשאבים בוחר את שלנו.
אתם צריכים גם לוודא שאתם לא מחליפים מחרוזת בטעות.
לכן יצרנו כלל build שמוודא שההחלפה מופיעה ברשימת ההיתרים, ואם לא, ה-build נכשל.
עכשיו נדבר על התראות.
כברירת מחדל, מערכת Wear OS מגשרת על ההתראות בטלפון או משקפת אותן.
זה נהדר, אבל זה עובד רק אם הטלפון מחובר.
אנחנו רוצים שוואטסאפ תפעל באופן עצמאי, ולכן האפליקציה שלנו מפרסמת את ההתראות שלנו.
אבל אם הגישור נשאר מופעל, כל הודעה גורמת לשני רטטים.
השבתת הגישור פותרת את בעיית הכפילויות.
יש דגל מניפסט סטטי, אבל הוא חל מההתקנה לפני שהמשתמש מתחבר.
אז אם מתקינים את האפליקציה ולא מתחברים, פתאום לא מקבלים התראות, וזה לא טוב, נכון?
לכן אנחנו מגדירים אותו בזמן הריצה באמצעות BridgingManager.
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())
}כשאתם מתחברים, אנחנו משביתים את הגישור.
כשתתנתקו מהחשבון, נפעיל אותה מחדש.
עם זאת, חשוב לזכור שאין API לקריאת הערכים בחזרה, וראינו שהשעון מאבד אותם.
לכן, אנחנו כותבים אותו שוב בכל פעם שהשעון מנותק מהמטען או כשיש עדכון לאפליקציה.
הנושא האחרון שרציתי לדבר עליו הוא איך מתנתקים מהחשבון.
כשמבטלים את הקישור של השעון לחשבון, אנשים מצפים שההודעות יוסרו ממנו.
לכן אנחנו מוחקים הכול – הודעות, מפתחות, הגדרות.
אבל איך עושים את זה בצורה בטוחה?
אם מוחקים קבצים בזמן שתהליכי רקע עדיין כותבים אליהם, מקבלים חריגות SQLite, או גרוע מכך, כותבים למסדי נתונים שנוצרו מחדש.
ואי אפשר באמת לנקות את המצב של תהליך בלי לעצור אותו.
ב-Android יש את ה-API clearApplicationUserData, שמוחק את הנתונים ועוצר את האפליקציה, אבל גם מאפס את ההרשאות.
בחלק מהמכשירים, העברת ההתראות קשורה להרשאות לשליחת התראות, כך שאם תשתמשו בה, פתאום תאבדו גם את שיקוף הטלפון.
לכן אנחנו מפסיקים את התהליך בעצמנו.
clearActivityBackStack()
// enumerate our own, kill each
killOtherProcesses()
// no threads alive — now safe
deleteDataDirectories()
// show logged out confirmation
firePostCleanupIntent()אנחנו יוצרים שירות קטן בתהליך משני, שאין בו הזרקת תלויות (DI) או מנהל עבודה, כך שהוא לא מפעיל את שאר האפליקציה.
אחרי שאתם מתנתקים, השירות הזה סוגר את כל התהליכים האחרים באפליקציה שלנו, ורק אז מוחק את כל הנתונים כשהם לא מתחרים על משאבים.
אנחנו לא משתמשים ב-clearApplicationUserData, אז ההרשאות נשמרות ואנחנו יכולים להפעיל את גישור ההתראות, כדי שההתראות מהטלפון יחזרו.
זה היה החלק הראשון, ועכשיו אעביר את רשות הדיבור לסובהאג שידבר על רענון ממשק המשתמש שלנו.
תודה, מרטין.
אחרי שהתשתית המשותפת הוקמה, התמקדנו ביצירת חוויית שימוש בוואטסאפ שמתאימה בדיוק לפרק כף היד.
החלק הראשון של התהליך היה המעבר ל-Material 3, אבל קודם נסביר למה.
במסך עגול שבו כל פיקסל חשוב, Material 3 נתן לנו את הרכיבים הנכונים ואת בסיס העיצוב כדי להתאים את וואטסאפ לפרק כף היד.
אם תסתכלו על הרכיבים החזותיים, על פני השטח, זה עשוי להיראות כמו עדכון של ממשק המשתמש.
היינו בתהליך מעבר מ-Material 2 ל-Material 3, וזה לא קורה ביום אחד.
הקוד הישן והקוד החדש היו צריכים להתקיים זה לצד זה מאחורי מתג.
כתוצאה מכך, המסכים שתאמו מבחינה חזותית ל-Figma עדיין הציגו התנהגויות לא תקינות.
לדוגמה, גלילה באמצעות הכתר, החלקה ימינה כדי לחזור אחורה, חלק מהתנועות הבסיסיות ביותר לא עבדו.
כלומר, מעבר למערכת עיצוב חדשה הוא לא רק מעבר חזותי, אלא גם מעבר התנהגותי.
אם תסתכלו על השורות ליד הקצוות, תראו שהן צרות יותר ומתעקלות.
זה TransformingLazyColumn.
השורות משתנות כדי שהמסגרת העגולה לא תחתוך אותן.
מסך דינמי יכול להקשות על התהליך.
הודעות חדשות מגיעות, שורות זזות ובועות מופיעות בכל מיני צורות.
בואו נראה מה זה אומר לעשות את זה נכון.
אם תסתכלו על הצד השמאלי, הבועות מתכווצות בצורה אגרסיבית מדי.
כל הודעה היא בעצם בועה בתוך כרטיס.
הגדרנו לכרטיס הראשי להחיל טרנספורמציה, אבל אותה טרנספורמציה הועברה גם לבועה המשנית.
כתוצאה מכך, הבועה המשנית מקבלת כמות כפולה של הטרנספורמציה המיועדת.
הפתרון היה לוודא שאנחנו מחילים טרנספורמציה ברמה אחת, וזה היה בכרטיס ההורה.
וזה גם עיקרון חשוב.
וידאנו שכל טרנספורמציה מתבצעת על פריט אחד.
והנה עוד בעיה: בועות קטנות ודהויות ממש באמצע המסך, במקום שבו הן אמורות להיות בגודל מלא.
הגודל הוא פונקציה של המיקום שלהם במסך.
בגרסה הראשונית, שמרנו את התאמת הגודל באמצעות הפונקציה remember של Compose, אבל זה גם שמר את המיקום הישן.
כתוצאה מכך, כשמגיעה הודעה חדשה והבועות הישנות יותר זזות למעלה, הטרנספורמציה שלהן עדיין מבוססת על המיקום הישן שלהן.
וזה גורם לבאג הזה.
כדי לתקן את זה, הפסקנו לשמור את הגודל באמצעות הקריאה remember והסרנו את הקריאה.
וגם זה שיעור חשוב.
כששורה זזה, הטרנספורמציה שלה צריכה לזוז איתה.
כאן אפשר לראות את שני התיקונים בקוד.
// 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)
}בפינה הימנית העליונה, אפשר לראות שהטרנספורמציה מיושמת בשתי רמות, ובפינה השמאלית העליונה, היא מיושמת רק ברמה אחת, שהיא כרטיס M3.
בפינה הימנית התחתונה, אנחנו משתמשים בקריאה ל-remember כדי לשמור במטמון את הטרנספורמציה, אבל היא גם שומרת במטמון את המיקום.
ובפינה הימנית התחתונה, אנחנו מחשבים את הטרנספורמציה בהתחשב במיקום האחרון של הבועה בכל פעם.
כמו שאפשר לראות, התוצאה היא חוויה עקבית בכל המסכים, שבה התוכן העיקרי נשאר קרוב למרכז, והשורות זזות באופן טבעי ליד הקצה.
עכשיו נדבר על התראות.
עכשיו דמיינו שאתם מרגישים רטט בפרק כף היד, מסתכלים על השעון ורואים הודעה.
אבל כמה רגעים אחר כך, השעון רוטט שוב ושוב.
אתם מקבלים כמה התראות, ובכל פעם שאתם בודקים, אתם רואים את אותו הדבר.
זה ממש מעצבן.
כדי לפתור את הבעיה, שאלנו את עצמנו שתי שאלות.
איזה אירוע אמור להפעיל את ההתראה למשתמש?
ואיזה אירוע אמור לעדכן את ההתראות בלי להשמיע צליל?
הבאג הזה קרה כי מאחורי הקלעים, עדכונים של תמונת הפרופיל, הורדות של מדיה ואישורי קבלה, שאמורים לעדכן את ההתראה באופן שקט, התריעו למשתמש במקום זאת.
עכשיו, כשמגיעה הודעה חדשה, פרק כף היד רוטט פעם אחת בדיוק, ואירועים שלא דורשים התראה מרעננים את ההתראה באופן שקט.
וגם זה שיעור.
כששליחת התראות היא אחד מהתהליכים העיקריים שהמשתמשים עוברים, אנחנו צריכים לוודא שכל התראה באמת ראויה לתשומת הלב של המשתמש.
בנושא ההתראות.
בעבר, ההתראות של וואטסאפ ב-Wear OS אפשרו לכם לעשות שני דברים.
אתם יכולים לקרוא את ההודעה או לפתוח את האפליקציה.
אם רציתם להשיב להתראה ישירות, לא יכולתם לעשות זאת קודם.
לכן גישרנו על הפער הזה באמצעות פעולת ההתראה של Android עם RemoteInput API.
עכשיו המשתמש יכול להקליד את התשובה, לומר אותה או ללחוץ על תשובה מהירה.
אבל חשוב לזכור שהעבודה לא נגמרת אחרי שלוחצים על "שליחה".
אם ההודעה נשלחת בהצלחה, אנחנו מסמנים את הצ'אט כ"כבר קראתי" ומנקים את ההתראה.
אם הודעה לא נשלחת, אפשר לנסות לשלוח אותה שוב בלחיצה אחת.
וזה גם לקח חשוב שלמדנו בזמן הפיתוח: צריך להתייחס לפעולה של התראה כתהליך עבודה מלא מקצה לקצה.
עכשיו נעבור למשבצות ולווידג'טים.
כמו שאפשר לראות, כרטיס מידע הוא מסך מלא שאפשר להגיע אליו בהחלקה מתצוגת השעון.
לעומת זאת, ווידג'טים הם כמה כרטיסים קטנים יותר, ואפשר להציג כמה מהם במסך אחד שניתן לגלול בו.
גם המשבצות וגם הווידג'טים של וואטסאפ מבוססים על אותם נתונים.
וגם זה טוויסט.
אולי נראה לכם שממשק המשתמש הזה נוצר על ידי האפליקציה שלנו, אבל זה לא המצב.
אנחנו רק מתארים את ממשק המשתמש ואת הנתונים, והמערכת משרטטת אותו.
בנוסף, לחיצה על אחד מהסמלים לא יכולה להפעיל את הקוד של האפליקציה.
אתם יכולים רק לבקש ממנו לפתוח מסך.
זהו, סיימנו את השיעור.
אם המערכת מציגה את ממשק המשתמש, הוא חלק מהמוצר וממדדי הבדיקה.
פרנק ויורי ירחיבו על ווידג'טים בשיחה שלהם.
וזה מביא אותי לשקף האחרון, שבו נחזור צעד אחד אחורה ונשאל את עצמנו, בסופו של דבר, אנחנו רוצים שעוד ועוד משתמשים ישתמשו באפליקציות שלנו.
עכשיו דמיינו שאתם מפתחים, ויום אחד אתם נכנסים למרכזי הבקרה שלכם ורואים ירידה חדה במספר המשתמשים הפעילים.
קודם כל, זה יהיה מאוד מדאיג.
ואז אתם יכולים להתחיל לנפות באגים.
יכול להיות שתרגישו שהייתה קריסה או שיש בעיה בגרסה.
אבל במקרה שלנו, אף אחת מהאפשרויות האלה לא הייתה רלוונטית.
מסתבר שלכל גרסה של אפליקציית וואטסאפ יש תוקף מובנה של 90 יום.
במצב כזה, המשתמשים תקועים עם גרסה ישנה, בזמן שלוקח זמן עד שהגרסאות החדשות יותר מגיעות לשעונים שלהם.
כתוצאה מכך, המשתמשים לא יוצאים מהאפליקציה, אבל האפליקציה שלהם מפסיקה לפעול.
לכן, עדכון האפליקציה הוא חלק מהמוצר.
כמו שאפשר לראות, 15 ימים לפני שהאפליקציה תפוג, ההודעה הזו מופיעה במסך.
ההודעה תופיע שוב כשיישארו 7, 5, 3, 2 ויום אחד.
המשתמשים יכולים לבחור אם לסגור את ההודעה או לעבור לחנות Play כדי לעדכן את האפליקציה.
כך המשתמשים מקבלים כמה תזכורות על כך שתוקף האפליקציה עומד לפוג, וגם כמה הזדמנויות לעדכן את האפליקציה.
וזה הדבר האחרון שלמדנו.
העבודה שלנו כמפתחים לא מסתיימת כשאנחנו משיקים עדכון ל-100% מהמשתמשים.
הוא מסתיים רק כשהמשתמשים מקבלים את העדכונים שלנו.
זהו, הגענו לסוף.
אם אתם רוצים להמשיך את השיחה, אתם מוזמנים להצטרף לשעות הקבלה.
עכשיו פרנק ויורי ידברו על ווידג'טים ל-Wear.
תודה.
@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)
}