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

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

יצירת פלאגין

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

ראו דוגמאות ב-Song וב-Gradle:

סונג

נבחן את הדוגמה הזו של סונג:

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>

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

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

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

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

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

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

  • משתמשים בתג library מתחת לתג application עם חבילת יישומי הפלאגין בקובץ המניפסט של האפליקציה:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • צריך להגדיר את כלל ה-build של android_app ב-Sung (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 של הפלאגין.

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

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

פלאגין של שרת Proxy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

מבחינת האפליקציה, כשהפלאגין שולח רכיבי inset חדשים, הוא יקבל אותם מפעילויות או מקטעים שמטמיעים את InsetsChangedListener. אם המיקום פעילות או מקטע לא מטמיעים את InsetsChangedListener, ממשק המשתמש של הרכב הספרייה תטפל בכניסות פנימיות כברירת מחדל על ידי החלת הרכיבים הפנימיים כמרווח פנימי Activity או FragmentActivity שמכילים את המקטע. הספרייה לא להחיל את inset כברירת מחדל על מקטעים. הנה קטע קוד לדוגמה שמחילה את הרכיבים הפנימיים בתור מרווח פנימי ב-RecyclerView app:

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 ועוברת את הבדיקה את הקריאה ל-listener שסופק כ-TextView של סרגל החיפוש שלו.

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

AppStyledView

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

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

הקשרים

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

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

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

שינויים במצב

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

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

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

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

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