פלאגינים של ממשק המשתמש ברכב

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

יצירת פלאגין

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

דוגמאות ב-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')

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

  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" להגדרת הספק. דוגמה לרשומה במניפסט:

    <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, המחלקות שלהם יתווספו אוטומטית לטוען המחלקות המשותף בין האפליקציות. כשבאפליקציה שמשתמשת בספריית ממשק המשתמש שברכב מוגדר runtime dependency בספרייה המשותפת של הפלאגין, טוען המחלקות שלה יכול לגשת למחלקות של הספרייה המשותפת של הפלאגין. תוספים שמיושמים כאפליקציות רגילות ל-Android (לא כספרייה משותפת) יכולים להשפיע לרעה על זמני ההפעלה הקרה של האפליקציה.

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

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

  • משתמשים בתג library מתחת לתג application עם שם חבילת הפלאגין בקובץ מניפסט של אפליקציה של הפלאגין:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • מגדירים את כלל ה-build של Soong‏ (android_app) עם הדגל AAPT‏ (shared-lib), שמשמש ליצירת ספרייה משותפת:Android.bp
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 מדווח על שגיאה שלפיה הוא לא מוצא פעילות ראשית להפעלה. זה צפוי, כי לתוסף אין פעילויות (חוץ מאובייקט ה-Intent הריק שמשמש לפתרון Intent). כדי לבטל את השגיאה, משנים את האפשרות הפעלה לללא בהגדרת הבנייה.

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

פלאגין Proxy

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

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

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

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

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

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

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

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

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

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

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

כדי לשמור על תאימות לאחור עם אפליקציות שעברו קומפילציה מול גרסאות ישנות יותר של ספריית Car Ui סטטית, מומלץ לתמוך ב-maxVersions בגרסה 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. פריסת הבסיס היא קונספט שמאפשר למקם את סרגל הכלים בכל מקום מסביב לתוכן של האפליקציה, כדי לאפשר סרגל כלים בחלק העליון או התחתון של האפליקציה, אנכית לאורך הצדדים, או אפילו סרגל כלים מעגלי שמקיף את כל האפליקציה. כדי לעשות את זה, מעבירים תצוגה אל installBaseLayoutAround כדי שסרגל הכלים או פריסת הבסיס יקיפו אותה.

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

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

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

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

מנקודת המבט של האפליקציה, כשפלאגין שולח תמונות ממוזערות חדשות, הוא מקבל אותן מכל הפעילויות או הקטעים שמטמיעים את InsetsChangedListener. אם פעילות או מקטע לא מטמיעים את InsetsChangedListener, ספריית Car Ui תטפל בתוספות כברירת מחדל על ידי החלת התוספות כריווח פנימי על Activity או FragmentActivity שמכילים את המקטע. כברירת מחדל, הספרייה לא מחילה את השוליים הפנימיים על קטעים. הנה דוגמה לקטע קוד של הטמעה שבה המרווחים הפנימיים מוחלים כריווח פנימי ב-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 צריכה להיות פשוטה יותר ליישום מהפריסה הבסיסית. המטרה שלו היא לקבל מידע שמועבר לשיטות ה-setter שלו ולהציג אותו בפריסת הבסיס. מידע על רוב השיטות מופיע ב-Javadoc. בהמשך מוסבר על כמה מהשיטות המורכבות יותר.

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

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

הקשרים

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

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

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

שינויים במצב

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

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

אפשר להטמיע תוספים באמצעות Jetpack Compose, אבל זו תכונה ברמת אלפא ולא מומלץ להסתמך עליה.

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

בעיה מרכזית אחת בשימוש ב-ComposeView היא שהיא מגדירה תגים בתצוגת הבסיס בפריסה כדי לאחסן משתנים גלובליים שמשותפים בין רכיבי ComposeView שונים בהיררכיה. מכיוון שמזהי המשאבים של הפלאגין לא מוגדרים במרחב שמות בנפרד מהאפליקציה, יכולות להיווצר התנגשויות אם גם האפליקציה וגם הפלאגין מגדירים תגים באותה תצוגה. בהמשך מופיע קוד מותאם אישית 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)
//  }
}