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

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

יצירת פלאגין

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

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

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

  • משתמשים בתג library מתחת לתג application עם שם חבילת הפלאגין במניפסט האפליקציה של הפלאגין:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • מגדירים את כלל ה-build של 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 שגורם לכך שעדכונים לתוסף לא נכנסים לתוקף. כדי לפתור את הבעיה, צריך לבחור באפשרות Always install with package manager (disables deploy optimizations on Android 11 and later) במבנה של הפלאגין.

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

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

פלאגין ל-Proxy

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

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

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.

AppStyledView

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

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

הקשרים

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

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