יישומי פלאגין בממשק המשתמש ברכב

אפשר להשתמש בפלאגינים של ספריית ממשק המשתמש שברכב כדי ליצור הטמעות מלאות של התאמות אישיות של רכיבים בספריית ממשק המשתמש שברכב, במקום להשתמש בשכבות-על של משאבים בסביבת זמן ריצה (RRO). באמצעות RRO אפשר לשנות רק את משאבי ה-XML של הרכיבים בספריית ממשק המשתמש ברכב, ולכן האפשרויות להתאמה אישית מוגבלות.

יצירת פלאגין

פלאגין של ספריית ממשק המשתמש ברכב הוא קובץ APK שמכיל כיתות שמטמיעות קבוצה של ממשקי API של פלאגין. אפשר לקמפל את Plugin APIs לפלאגין כספרייה סטטית.

דוגמאות ב-Soong וב-Gradle:

סונג

הנה דוגמה ל-Soong:

android_app {
    name: "my-plugin",

    min_sdk_version: "28",
    target_sdk_version: "30",
    aaptflags: ["--shared-lib"],
    sdk_version: "current",

    manifest: "src/main/AndroidManifest.xml",
    srcs: ["src/main/java/**/*.java"],
    resource_dirs: ["src/main/res"],
    static_libs: [
        "car-ui-lib-oem-apis",
    ],
    // Disable optimization is mandatory to prevent R.java class from being
    // stripped out
    optimize: {
        enabled: false,
    },

    certificate: ":my-plugin-certificate",
}

Gradle

קובץ build.gradle:

apply plugin: 'com.android.application'

android {
  compileSdkVersion 30

  defaultConfig {
    minSdkVersion 28
    targetSdkVersion 30
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  signingConfigs {
    debug {
      storeFile file('chassis_upload_key.jks')
      storePassword 'chassis'
      keyAlias 'chassis'
      keyPassword 'chassis'
    }
  }
}

dependencies {
  implementation project(':oem-apis')
  // Or use the following if you'd like to use the maven artifact
  // implementation 'com.android.car.ui:car-ui-lib-plugin-apis:1.0.0'
}

Settings.gradle:

// You can remove the ':oem-apis' if you're using the maven artifact.
include ':oem-apis'
project(':oem-apis').projectDir = new File('./path/to/oem-apis')
include ':my-plugin'
project(':my-plugin').projectDir = new File('./my-plugin')

ב-Manifest של הפלאגין צריך להצהיר על ספק תוכן עם המאפיינים הבאים:

  android:authorities="com.android.car.ui.plugin"
  android:enabled="true"
  android:exported="true"

android:authorities="com.android.car.ui.plugin" מאפשר לספריית ממשק המשתמש שברכב לזהות את הפלאגין. צריך לייצא את הספק כדי שאפשר יהיה לשלוח שאילתות אליו במהלך זמן הריצה. כמו כן, אם המאפיין enabled מוגדר כ-false, המערכת תשתמש בהטמעה שמוגדרת כברירת מחדל במקום בהטמעה של הפלאגין. אין צורך שכייתה של ספק התוכן תהיה קיימת. במקרה כזה, חשוב להוסיף את הערך tools:ignore="MissingClass" להגדרת הספק. דוגמה לרשומה ב-manifest:

    <application>
        <provider
            android:name="com.android.car.ui.plugin.PluginNameProvider"
            android:authorities="com.android.car.ui.plugin"
            android:enabled="false"
            android:exported="true"
            tools:ignore="MissingClass"/>
    </application>

לבסוף, כצעד אבטחה, חותמים על האפליקציה.

יישומי פלאגין כספרייה משותפת

בניגוד לספריות סטטיות של Android שמתורגמות ישירות לאפליקציות, ספריות משותפות של Android מתורגלות ל-APK עצמאי שאפליקציות אחרות מפנות אליו בזמן הריצה.

הכיתות של יישומי הפלאגין שמוטמעים כספרייה משותפת של Android מתווספות באופן אוטומטי למטען הכיתות המשותף בין האפליקציות. כשאפליקציה שמשתמשת בספריית ממשק המשתמש ברכב מציינת תלות בסביבת זמן הריצה בספרייה המשותפת של הפלאגין, מעבד הטעינה שלה יכול לגשת לכיתות של הספרייה המשותפת של הפלאגין. יישומי פלאגין שמוטמעים כאפליקציות רגילות ל-Android (ולא כספרייה משותפת) יכולים להשפיע לרעה על זמני ההפעלה הראשונית של האפליקציה.

הטמעה ופיתוח של ספריות משותפות

פיתוח באמצעות ספריות משותפות של Android דומה מאוד לפיתוח של אפליקציות Android רגילות, אבל יש כמה הבדלים עיקריים.

  • משתמשים בתג library מתחת לתג application עם שם החבילה של הפלאגין במניפסט האפליקציה של הפלאגין:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • מגדירים את כלל ה-build android_app של Soong (Android.bp) עם הדגל shared-lib של AAPT, שמשמשים ליצירת ספרייה משותפת:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

יחסי תלות בספריות משותפות

לכל אפליקציה במערכת שמשתמשת בספריית ממשק המשתמש ברכב, צריך לכלול את התג uses-library במניפסט של האפליקציה מתחת לתג application עם שם החבילה של הפלאגין:

<manifest>
  <application
      android:name=".MyApp"
      ...>
    <uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
    ...
  </application>
</manifest>

התקנת פלאגין

פלאגינים חייבים להיות מותקנים מראש במחיצה של המערכת, על ידי הכללת המודול ב-PRODUCT_PACKAGES. אפשר לעדכן את החבילה שהותקנה מראש באופן דומה לכל אפליקציה אחרת שמותקנת.

אם מעדכנים פלאגין קיים במערכת, כל האפליקציות שמשתמשות בפלאגין הזה נסגרות באופן אוטומטי. אחרי שהמשתמש יפתח מחדש את הקובץ, השינויים המעודכנים יופיעו בו. אם האפליקציה לא הייתה פועלת, בפעם הבאה שהיא תופעל היא תכלול את הפלאגין המעודכן.

כשמתקינים פלאגין באמצעות Android Studio, יש כמה דברים נוספים שצריך לקחת בחשבון. נכון למועד כתיבת המאמר, יש באג בתהליך ההתקנה של אפליקציית Android Studio שגורם לכך שהעדכונים של הפלאגין לא ייכנסו לתוקף. כדי לפתור את הבעיה, בוחרים באפשרות Always install with package manager (disables deploy optimizations on Android 11 and later) בתצורת ה-build של הפלאגין.

בנוסף, בזמן התקנת הפלאגין, מוצגת ב-Android Studio הודעת שגיאה על כך שלא ניתן למצוא פעילות ראשית להפעלה. זה צפוי, כי לפלאגין אין פעילויות (מלבד הכוונה הריקה שמשמש לפתרון הכוונה). כדי לבטל את השגיאה, משנים את האפשרות Launch ל-Nothing בתצורת ה-build.

הגדרת הפלאגין ב-Android Studio איור 1. הגדרת הפלאגין ב-Android Studio

פלאגין proxy

כדי להתאים אישית אפליקציות באמצעות ספריית ממשק המשתמש ברכב, צריך קובץ RRO שמטרגט כל אפליקציה ספציפית שרוצים לשנות, גם אם ההתאמות האישיות זהות בכל האפליקציות. כלומר, נדרש RRO לכל אפליקציה. לראות באילו אפליקציות נעשה שימוש בספריית ממשק המשתמש ברכב.

הפלאגין של שרת ה-proxy של ספריית ממשק המשתמש שברכב הוא דוגמה לספרייה משותפת של פלאגין שמעבירה את הטמעות הרכיבים שלו לגרסה הסטטית של ספריית ממשק המשתמש שברכב. אפשר לטרגט את הפלאגין הזה באמצעות RRO, שאפשר להשתמש בו כנקודת התאמה אישית אחת לאפליקציות שמשתמשות בספריית ממשק המשתמש ברכב, בלי צורך להטמיע פלאגין פונקציונלי. מידע נוסף על RRO זמין במאמר שינוי הערך של המשאבים של אפליקציה בסביבת זמן הריצה.

הפלאגין לשרת proxy הוא רק דוגמה ונקודת התחלה לביצוע התאמה אישית באמצעות פלאגין. כדי לבצע התאמה אישית מעבר ל-RRO, אפשר להטמיע קבוצת משנה של רכיבי הפלאגין ולהשתמש בפלאגין שרת ה-proxy בשאר הרכיבים, או להטמיע את כל רכיבי הפלאגין מחדש מאפס.

פלאגין ה-proxy מספק נקודה אחת של התאמה אישית של RRO לאפליקציות, אבל אפליקציות שמבטלות את ההסכמה לשימוש בפלאגין עדיין יצטרכו RRO שמטרגט ישירות את האפליקציה עצמה.

הטמעת ממשקי ה-API של הפלאגין

נקודת הכניסה הראשית לפלאגין היא הכיתה com.android.car.ui.plugin.PluginVersionProviderImpl. כל הפלאגינים חייבים לכלול כיתה עם השם הזה ועם שם החבילה הזה. המחלקה הזו צריכה לכלול קונסטרוקטור שמוגדר כברירת מחדל ולהטמיע את הממשק PluginVersionProviderOEMV1.

פלאגינים של CarUi חייבים לפעול עם אפליקציות ישנות יותר או חדשות יותר מהפלאגין. כדי להקל על כך, כל ממשקי ה-API של הפלאגין מחולקים לגרסאות באמצעות הסימן V# בסוף שם הכיתה שלהם. אם תשוחרר גרסה חדשה של ספריית ממשק המשתמש ברכב עם תכונות חדשות, הן יהיו חלק מגרסה V2 של הרכיב. ספריית ממשק המשתמש שברכב עושה כמיטב יכולתה כדי לאפשר לתכונות חדשות לפעול במסגרת רכיב פלאגין ישן יותר. לדוגמה, על ידי המרת סוג חדש של לחצן בסרגל הכלים ל-MenuItems.

עם זאת, אפליקציה עם גרסה ישנה יותר של ספריית ממשק המשתמש שברכב לא יכולה להתאים את עצמה לפלאגין חדש שנכתב עבור ממשקי API חדשים יותר. כדי לפתור את הבעיה הזו, אנחנו מאפשרים לפלאגינים להחזיר הטמעות שונות של עצמם על סמך גרסת ה-OEM API שנתמכת באפליקציות.

ב-PluginVersionProviderOEMV1 יש שיטה אחת:

Object getPluginFactory(int maxVersion, Context context, String packageName);

ה-method הזה מחזיר אובייקט שמטמיע את הגרסה הגבוהה ביותר של PluginFactoryOEMV# שנתמכת בפלאגין, אבל עדיין קטנה מ-maxVersion או שווה לה. אם לפלאגין אין הטמעה של PluginFactory ישנה כל כך, הוא עשוי להחזיר את הערך null. במקרה כזה, המערכת תשתמש בהטמעה המקושרת באופן סטטי של רכיבי CarUi.

כדי לשמור על תאימות לאחור לאפליקציות שעבר תהליך הידור לגרסאות ישנות יותר של ספריית Car UI הסטטית, מומלץ לתמוך ב-maxVersion בגרסאות 2, 5 ואילך מתוך ההטמעה של הפלאגין של הכיתה PluginVersionProvider. אין תמיכה בגרסאות 1, 3 ו-4. למידע נוסף, ראו PluginVersionProviderImpl.

PluginFactory הוא הממשק שיוצר את כל שאר הרכיבים של CarUi. הוא גם מגדיר באיזו גרסה של הממשקים שלהם צריך להשתמש. אם הפלאגין לא מנסה להטמיע אף אחד מהרכיבים האלה, הוא יכול להחזיר את הערך null בפונקציית היצירה שלהם (למעט סרגל הכלים, שיש לו פונקציית customizesBaseLayout() נפרדת).

השדה pluginFactory מגביל את הגרסאות של רכיבי CarUi שאפשר להשתמש בהן יחד. לדוגמה, אף פעם לא תהיה pluginFactory שיכולה ליצור גרסה 100 של Toolbar וגם גרסה 1 של RecyclerView, כי אין הרבה ודאות שמגוון רחב של גרסאות של רכיבים יפעלו יחד. כדי להשתמש בגרסה 100 של סרגל הכלים, המפתחים צריכים לספק הטמעה של גרסה של pluginFactory שיוצרת סרגל כלים בגרסה 100. כך אפשר להגביל את האפשרויות לגבי הגרסאות של רכיבים אחרים שאפשר ליצור. הגרסאות של רכיבים אחרים עשויות להיות שונות, לדוגמה, pluginFactoryOEMV100 יכול ליצור ToolbarControllerOEMV100 ו-RecyclerViewOEMV70.

סרגל כלים

פריסת הבסיס

סרגל הכלים ו'פריסת הבסיס' קשורים מאוד, ולכן הפונקציה שיוצרת את סרגל הכלים נקראת installBaseLayoutAround. פריסת הבסיס היא מושג שמאפשר למקם את סרגל הכלים בכל מקום סביב התוכן של האפליקציה, כך שאפשר להציב סרגל כלים בחלק העליון או התחתון של האפליקציה, בצדדים או אפילו סרגל כלים עגול שמקיף את האפליקציה כולה. כדי לעשות זאת, מעבירים תצוגה (view) אל installBaseLayoutAround כדי שסרגל הכלים או פריסת הבסיס יכסו אותה.

הפלאגין צריך לקחת את התצוגה שסופקה, לנתק אותה מההורה, לנפח את הפריסה של הפלאגין באותו אינדקס של ההורה ובאותו LayoutParams כמו התצוגה שנותקה זה עתה, ואז לחבר מחדש את התצוגה במקום כלשהו בתוך הפריסה שנפחה זה עתה. הפריסה המורחבת תכלול את סרגל הכלים, אם האפליקציה תבקש זאת.

האפליקציה יכולה לבקש פריסה בסיסית ללא סרגל כלים. אם כן, הפונקציה installBaseLayoutAround אמורה להחזיר ערך null. ברוב הפלאגינים, זה כל מה שצריך לעשות, אבל אם מחבר הפלאגין רוצה להוסיף, למשל, קישוט מסביב לקצה האפליקציה, עדיין אפשר לעשות זאת באמצעות פריסה בסיסית. האפשרויות האלה לקישוט שימושיות במיוחד במכשירים עם מסכים לא מלבניים, כי הן מאפשרות לדחוף את האפליקציה למרחב מלבני ולהוסיף מעברים נקיים למרחב הלא מלבני.

installBaseLayoutAround מקבל גם Consumer<InsetsOEMV1>. אפשר להשתמש בצרכן הזה כדי להודיע לאפליקציה שהתוסף מכסה חלקית את התוכן שלה (באמצעות סרגל הכלים או בדרך אחרת). כך האפליקציה תדע להמשיך לצייר במרחב הזה, אבל לא לכלול בו רכיבים קריטיים שמשתמשים יכולים לקיים איתם אינטראקציה. האפקט הזה משמש בתכנון העזר שלנו כדי להפוך את סרגל הכלים למחצית שקוף, ולאפשר לגלול ברשימות שמתחתיו. אם התכונה הזו לא תוטמע, הפריט הראשון ברשימה יישאר מתחת לסרגל הכלים ולא תהיה אפשרות ללחוץ עליו. אם אין צורך בהשפעה הזו, הפלאגין יכול להתעלם מהצרכן.

גלילה של תוכן מתחת לסרגל הכלים איור 2. גלילה של תוכן מתחת לסרגל הכלים

מנקודת המבט של האפליקציה, כשהפלאגין שולח תצוגות מוטמעות חדשות, הוא מקבל אותן מכל הפעילויות או מהקטעים שמטמיעים את InsetsChangedListener. אם פעילות או מקטע לא מטמיעים את InsetsChangedListener, ספריית Car UI יטפלו ב-insets כברירת מחדל על ידי החלת ה-insets כמילוי (padding) ל-Activity או ל-FragmentActivity שמכיל את המקטע. כברירת מחדל, הספרייה לא מחילה את הקטעים הפנימיים על קטעי קוד. לפניכם קטע קוד לדוגמה להטמעה שמחילה את ה-insets כמילוי ב-RecyclerView באפליקציה:

public class MainActivity extends Activity implements InsetsChangedListener {
  @Override
  public void onCarUiInsetsChanged(Insets insets) {
    CarUiRecyclerView rv = requireViewById(R.id.recyclerview);
    rv.setPadding(insets.getLeft(), insets.getTop(),
                  insets.getRight(), insets.getBottom());
  }
}

לבסוף, הפלאגין מקבל רמז fullscreen, שמציין אם התצוגה שצריך לעטוף תופסת את כל האפליקציה או רק קטע קטן. אפשר להשתמש באפשרות הזו כדי להימנע משימוש בחלק מהקישוט שמוצג בצד, שרק הגיוני להציג אותו בצד של המסך כולו. אפליקציה לדוגמה שמשתמשת בפריסות בסיס שלא במסך מלא היא 'הגדרות', שבה לכל חלונית בפריסה של שתי חלוניות יש סרגל כלים משלה.

מכיוון ש-installBaseLayoutAround אמור להחזיר null כש-toolbarEnabled הוא false, כדי שהתוסף יציין שהוא לא רוצה להתאים אישית את פריסת הבסיס, הוא צריך להחזיר את הערך false מ-customizesBaseLayout.

כדי לתמוך באופן מלא באמצעי בקרה מסתובבים, הפריסה הבסיסית חייבת לכלול רכיב FocusParkingView ורכיב FocusArea. אפשר להשמיט את התצוגות האלה במכשירים שלא תומכים בתנועה סיבובית. ה-FocusParkingView/FocusAreas מוטמעים בספרייה הסטטית CarUi, ולכן setRotaryFactories משמש לספק מפעלים ליצירת התצוגות מההקשרים.

ההקשרים שבהם משתמשים כדי ליצור תצוגות של התמקדות חייבים להיות ההקשר של המקור, ולא ההקשר של הפלאגין. ה-FocusParkingView צריך להיות קרוב ככל האפשר לתצוגה הראשונה בעץ, כי הוא הרכיב שמתמקד בו כשאין רכיב אחר שגלוי למשתמש. ה-FocusArea צריך לעטוף את סרגל הכלים בפריסה הבסיסית כדי לציין שמדובר באזור של דחיפה סיבובית. אם לא מציינים את FocusArea, המשתמש לא יכול לנווט ללחצנים כלשהם בסרגל הכלים באמצעות הבקר האלקטרוני.

בקר סרגל הכלים

קל הרבה יותר להטמיע את הערך בפועל של ToolbarController שמוחזר מאשר את הפריסה הבסיסית. התפקיד שלו הוא לקבל מידע שמוענק למגדירים שלו ולהציג אותו בפריסה הבסיסית. למידע על רוב השיטות, אפשר לעיין ב-Javadoc. בהמשך מפורטות כמה מהשיטות המורכבות יותר.

getImeSearchInterface משמש להצגת תוצאות חיפוש בחלון ה-IME (המקלדת). אפשר להשתמש באפשרות הזו כדי להציג או להציג אנימציה של תוצאות חיפוש לצד המקלדת, למשל אם המקלדת תופסת רק חצי מהמסך. רוב הפונקציונליות מיושמת בספריית CarUi הסטטית, וממשק החיפוש בפלאגין מספק רק שיטות לספרייה הסטטית כדי לקבל את הקריאות החוזרות TextView ו-onPrivateIMECommand. כדי לתמוך בכך, צריך להשתמש בפלאגין בתת-מחלקה של TextView שמחליף את onPrivateIMECommand ומעביר את הקריאה למאזין שסופק כ-TextView של סרגל החיפוש.

setMenuItems פשוט מציג את הפריטים בתפריט במסך, אבל הוא ייכלל בתדירות גבוהה להפתיע. מאחר שממשק ה-API של הפלאגין ל-MenuItems לא ניתן לשינוי, בכל פעם שמשתנה MenuItem מתבצעת קריאה חדשה לגמרי ל-setMenuItems. זה יכול לקרות בגלל משהו פשוט כמו לחיצה של משתמש על MenuItem של מתג, והקליק הזה גרם למתג לעבור למצב אחר. לכן, גם מטעמי ביצועים וגם מטעמי אנימציה, מומלץ לחשב את ההבדל בין רשימת הפריטים הישנים לבין רשימת הפריטים החדשים ולעדכן רק את התצוגות שהשתנו בפועל. כדי לעזור בכך, פריטי התפריט כוללים את השדה key. המפתח צריך להיות זהה בכל הקריאות השונות ל-setMenuItems עבור אותו פריט תפריט.

AppStyledView

ה-AppStyledView הוא מאגר לתצוגה שלא מותאמת אישית בכלל. אפשר להשתמש בו כדי להציג מסגרת סביב התצוגה הזו, כך שהיא תבלוט משאר האפליקציה ותציין למשתמש שמדובר בממשק מסוג אחר. התצוגה שמקיפה את AppStyledView מוצגת ב-setContent. ב-AppStyledView יכול להופיע גם לחצן 'הקודם' או 'סגירה', בהתאם לבקשת האפליקציה.

AppStyledView לא מכניס את התצוגות שלו מייד להיררכיית התצוגות, כמו installBaseLayoutAround, אלא רק מחזיר את התצוגה שלו לספרייה הסטטית דרך getView, שמבצעת את ההוספה. אפשר גם להשתמש ב-getDialogWindowLayoutParam כדי לקבוע את המיקום והגודל של AppStyledView.

הקשרים

צריך להיזהר כשמשתמשים בפלחים בתוך הפלאגין, כי יש פלחים של פלאגין ופלחים של 'מקור'. הקשר של הפלאגין ניתן כארגומנט ל-getPluginFactory, והוא ההקשר היחיד שמכיל את המשאבים של הפלאגין. כלומר, זהו ההקשר היחיד שאפשר להשתמש בו כדי לנפח פריסות בתוסף.

עם זאת, יכול להיות שההגדרה הנכונה לא מוגדרת בהקשר של הפלאגין. כדי לקבל את ההגדרה הנכונה, אנחנו מספקים הקשרי מקור בשיטות שיוצרות רכיבים. הקשר המקור הוא בדרך כלל פעילות, אבל במקרים מסוימים הוא יכול להיות גם שירות או רכיב אחר של Android. כדי להשתמש בהגדרות מההקשר של המקור עם המשאבים מההקשר של הפלאגין, צריך ליצור הקשר חדש באמצעות createConfigurationContext. אם לא משתמשים בהגדרה הנכונה, תהיה הפרה של מצב קפדני ב-Android, ויכול להיות שהתצוגות המורחבות לא יהיו בגודל הנכון.

Context layoutInflationContext = pluginContext.createConfigurationContext(
        sourceContext.getResources().getConfiguration());

שינויים במצב

חלק מהיישומי פלאגין יכולים לתמוך במספר מצבים לרכיבים שלהם, כמו מצב ספורט או מצב אקונומי שונים מבחינה ויזואלית. אין תמיכה מובנית בפונקציונליות כזו ב-CarUi, אבל אין שום דבר שימנע מהפלאגין להטמיע אותה באופן פנימי לחלוטין. הפלאגין יכול לעקוב אחרי כל התנאים שהוא רוצה כדי להבין מתי לעבור בין המצבים, למשל האזנה לשידורים. הפלאגין לא יכול להפעיל שינוי בהגדרות כדי לשנות את המצבים, אבל בכל מקרה לא מומלץ להסתמך על שינויים בהגדרות, כי עדכון ידני של המראה של כל רכיב קל יותר למשתמש ומאפשר גם מעברים שלא ניתן לבצע באמצעות שינויים בהגדרות.

Jetpack פיתוח נייטיב

אפשר להטמיע יישומי פלאגין באמצעות Jetpack Compose, אבל זו תכונה ברמת אלפא ואין להתייחס אליה כאל תכונה יציבה.

תוכלו להשתמש ב-ComposeView ב-plugins כדי ליצור משטח תצוגה שתומך ב-Compose, שבו תוכלו לבצע עיבוד. הערך של ComposeView יהיה הערך שמוחזר מהאפליקציה מהשיטה getView ברכיבים.

אחת מהבעיות העיקריות בשימוש ב-ComposeView היא שהיא מגדירה תגים בתצוגת הבסיס בפריסה כדי לאחסן משתנים גלובליים ששותפו בין ComposeViews שונים בהיררכיה. מאחר שמזהי המשאבים של הפלאגין לא נמצאים במרחב שמות נפרד מזה של האפליקציה, ייתכן שייווצרו התנגשויות כאשר גם האפליקציה וגם הפלאגין יגדירו תגים באותו תצוגה. בהמשך מופיע ComposeViewWithLifecycle מותאם אישית שמפנה את המשתנים הגלובליים האלה אל ComposeView. שוב, לא צריך להתייחס לזה כאל מצב יציב.

ComposeViewWithLifecycle:

class ComposeViewWithLifecycle @JvmOverloads constructor(
  context: Context,
  attrs: AttributeSet? = null,
  defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
    LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {

  private val lifeCycle = LifecycleRegistry(this)
  private val modelStore = ViewModelStore()
  private val savedStateRegistryController = SavedStateRegistryController.create(this)
  private var composeView: ComposeView? = null
  private var content = @Composable {}

  init {
    ViewTreeLifecycleOwner.set(this, this)
    ViewTreeViewModelStoreOwner.set(this, this)
    ViewTreeSavedStateRegistryOwner.set(this, this)
    compositionContext = createCompositionContext()
  }

  fun setContent(content: @Composable () -> Unit) {
    this.content = content
    composeView?.setContent(content)
  }

  override fun getLifecycle(): Lifecycle {
    return lifeCycle
  }

  override fun getViewModelStore(): ViewModelStore {
    return modelStore
  }

  override fun getSavedStateRegistry(): SavedStateRegistry {
    return savedStateRegistryController.savedStateRegistry
  }

  override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    savedStateRegistryController.performRestore(Bundle())
    lifeCycle.currentState = Lifecycle.State.RESUMED
    composeView = ComposeView(context)
    composeView?.setContent(content)
    addView(composeView, LayoutParams(
      LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
  }

  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    lifeCycle.currentState = Lifecycle.State.DESTROYED
    modelStore.clear()
    removeAllViews()
    composeView = null
  }

  // Exact copy of View.createCompositionContext() in androidx's WindowRecomposer.android.kt
  private fun createCompositionContext(): CompositionContext {
    val currentThreadContext = AndroidUiDispatcher.CurrentThread
    val pausableClock = currentThreadContext[MonotonicFrameClock]?.let {
      PausableMonotonicFrameClock(it).apply { pause() }
    }
    val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
    val recomposer = Recomposer(contextWithClock)
    val runRecomposeScope = CoroutineScope(contextWithClock)
    val viewTreeLifecycleOwner = checkNotNull(ViewTreeLifecycleOwner.get(this)) {
      "ViewTreeLifecycleOwner not found from $this"
    }
    viewTreeLifecycleOwner.lifecycle.addObserver(
      LifecycleEventObserver { _, event ->
        @Suppress("NON_EXHAUSTIVE_WHEN")
        when (event) {
          Lifecycle.Event.ON_CREATE ->
            // Undispatched launch since we've configured this scope
            // to be on the UI thread
            runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
              recomposer.runRecomposeAndApplyChanges()
            }
          Lifecycle.Event.ON_START -> pausableClock?.resume()
          Lifecycle.Event.ON_STOP -> pausableClock?.pause()
          Lifecycle.Event.ON_DESTROY -> {
            recomposer.cancel()
          }
        }
      }
    )
    return recomposer
  }

//  TODO: ComposeViewWithLifecycle should handle saving state and other lifecycle things
//  override fun onSaveInstanceState(): Parcelable? {
//    val superState = super.onSaveInstanceState()
//    val bundle = Bundle()
//    savedStateRegistryController.performSave(bundle)
//  }
}