תוספים לממשק המשתמש לרכב

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

צור תוסף

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

ראה דוגמאות ב- Soong and 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",
}

גרדל

ראה קובץ 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>

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

תוספים כספרייה משותפת

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

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

יישום ובניית ספריות משותפות

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

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

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

תצורת פלאגין Android Studio איור 1. תצורת Plugin Android Studio

תוסף פרוקסי

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

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

תוסף ה-proxy הוא רק דוגמה ונקודת התחלה לביצוע התאמה אישית באמצעות תוסף. להתאמה אישית מעבר ל-RROs, אפשר ליישם תת-קבוצה של רכיבי תוסף ולהשתמש בתוסף ה-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);

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

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

תוכן גלילה מתחת לסרגל הכלים איור 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 משמש כדי לספק מפעלים ליצירת התצוגות מהקשרים.

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

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

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

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

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

AppStyledView

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

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

הקשרים

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

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

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

שינויים במצב

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

Jetpack Compose

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

תוספים יכולים להשתמש ComposeView כדי ליצור משטח התומך ב-Compose לעיבוד. 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)
//  }
}