Plugin giao diện người dùng ô tô

Sử dụng plugin thư viện Car UI để triển khai hoàn chỉnh các tùy chỉnh thành phần trong thư viện Car UI thay vì sử dụng lớp phủ tài nguyên thời gian chạy (RRO). RRO cho phép bạn chỉ thay đổi tài nguyên XML của các thành phần thư viện Car UI, điều này giới hạn phạm vi những gì bạn có thể tùy chỉnh.

Tạo một phần bổ trợ

Plugin thư viện Car UI là một APK chứa các lớp triển khai một bộ API Plugin . API plugin có thể được biên dịch thành plugin dưới dạng thư viện tĩnh.

Xem ví dụ trong Soong và Gradle:

Tống

Hãy xem xét ví dụ Soong này:

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",
}

Lớp

Xem tệp build.gradle này:

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')

Plugin phải có nhà cung cấp nội dung được khai báo trong tệp kê khai có các thuộc tính sau:

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

android:authorities="com.android.car.ui.plugin" làm cho plugin có thể được phát hiện trong thư viện Car UI. Nhà cung cấp phải được xuất để có thể truy vấn nó trong thời gian chạy. Ngoài ra, nếu thuộc tính enabled được đặt thành false thì việc triển khai mặc định sẽ được sử dụng thay vì triển khai plugin. Lớp nhà cung cấp nội dung không nhất thiết phải tồn tại. Trong trường hợp đó, hãy nhớ thêm tools:ignore="MissingClass" vào định nghĩa nhà cung cấp. Xem mục nhập bảng kê khai mẫu bên dưới:

    <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>

Cuối cùng, như một biện pháp bảo mật, hãy Ký ứng dụng của bạn .

Plugin như một thư viện được chia sẻ

Không giống như các thư viện tĩnh của Android được biên dịch trực tiếp vào ứng dụng, thư viện dùng chung của Android được biên dịch thành một APK độc lập được các ứng dụng khác tham chiếu trong thời gian chạy.

Các plugin được triển khai dưới dạng thư viện dùng chung của Android sẽ tự động thêm các lớp của chúng vào trình nạp lớp dùng chung giữa các ứng dụng. Khi một ứng dụng sử dụng thư viện Car UI chỉ định phần phụ thuộc thời gian chạy trên thư viện chia sẻ plugin, trình nạp lớp của ứng dụng đó có thể truy cập các lớp của thư viện chia sẻ plugin. Các plugin được triển khai như ứng dụng Android thông thường (không phải thư viện dùng chung) có thể tác động tiêu cực đến thời gian khởi động nguội của ứng dụng.

Triển khai và xây dựng thư viện dùng chung

Việc phát triển bằng thư viện dùng chung của Android rất giống với việc phát triển các ứng dụng Android thông thường, với một số điểm khác biệt chính.

  • Sử dụng thẻ library trong thẻ application với tên gói plugin trong bảng kê khai ứng dụng plugin của bạn:
    <application>
        <library android:name="com.chassis.car.ui.plugin" />
        ...
    </application>
  • Định cấu hình quy tắc xây dựng Soong android_app ( Android.bp ) bằng cờ AAPT shared-lib , được sử dụng để xây dựng thư viện dùng chung:
android_app {
  ...
  aaptflags: ["--shared-lib"],
  ...
}

Phụ thuộc vào thư viện dùng chung

Đối với mỗi ứng dụng trên hệ thống sử dụng thư viện Car UI, hãy bao gồm thẻ uses-library trong tệp kê khai ứng dụng bên dưới thẻ application có tên gói plugin:

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

Cài đặt một plugin

Các plugin PHẢI được cài đặt sẵn trên phân vùng hệ thống bằng cách đưa mô-đun vào PRODUCT_PACKAGES . Gói cài đặt sẵn có thể được cập nhật tương tự như bất kỳ ứng dụng đã cài đặt nào khác.

Nếu bạn đang cập nhật plugin hiện có trên hệ thống thì mọi ứng dụng sử dụng plugin đó sẽ tự động đóng. Sau khi người dùng mở lại, họ sẽ có những thay đổi được cập nhật. Nếu ứng dụng không chạy thì lần khởi động tiếp theo ứng dụng sẽ có plugin được cập nhật.

Khi cài đặt plugin bằng Android Studio, bạn cần cân nhắc thêm một số điều cần cân nhắc. Tại thời điểm viết bài, có một lỗi trong quá trình cài đặt ứng dụng Android Studio khiến các bản cập nhật plugin không có hiệu lực. Điều này có thể được khắc phục bằng cách chọn tùy chọn Luôn cài đặt bằng trình quản lý gói (vô hiệu hóa tối ưu hóa triển khai trên Android 11 trở lên) trong cấu hình bản dựng của plugin.

Ngoài ra, khi cài đặt plugin, Android Studio báo lỗi không tìm thấy hoạt động chính để khởi chạy. Điều này được mong đợi vì plugin không có bất kỳ hoạt động nào (ngoại trừ mục đích trống dùng để giải quyết một ý định). Để loại bỏ lỗi, hãy thay đổi tùy chọn Khởi chạy thành Không có gì trong cấu hình bản dựng.

Cấu hình plugin Android Studio Hình 1. Cấu hình plugin Android Studio

Plugin proxy

Việc tùy chỉnh ứng dụng bằng thư viện Car UI yêu cầu RRO nhắm mục tiêu từng ứng dụng cụ thể cần sửa đổi, kể cả khi các tùy chỉnh giống hệt nhau giữa các ứng dụng. Điều này có nghĩa là cần có RRO cho mỗi ứng dụng. Xem ứng dụng nào sử dụng thư viện Car UI.

Plugin proxy thư viện Car UI là một thư viện chia sẻ plugin mẫu ủy quyền việc triển khai thành phần của nó cho phiên bản tĩnh của thư viện Car UI. Plugin này có thể được nhắm mục tiêu bằng RRO, có thể được sử dụng làm điểm tùy chỉnh duy nhất cho các ứng dụng sử dụng thư viện Car UI mà không cần triển khai plugin chức năng. Để biết thêm thông tin về RRO, hãy xem Thay đổi giá trị tài nguyên của ứng dụng khi chạy .

Plugin proxy chỉ là một ví dụ và là điểm bắt đầu để thực hiện tùy chỉnh bằng cách sử dụng plugin. Để tùy chỉnh ngoài RRO, người ta có thể triển khai một tập hợp con các thành phần plugin và sử dụng plugin proxy cho phần còn lại hoặc triển khai tất cả các thành phần plugin hoàn toàn từ đầu.

Mặc dù plugin proxy cung cấp một điểm tùy chỉnh RRO duy nhất cho ứng dụng, nhưng các ứng dụng chọn không sử dụng plugin sẽ vẫn yêu cầu RRO nhắm mục tiêu trực tiếp vào chính ứng dụng đó.

Triển khai API plugin

Điểm truy cập chính của plugin là lớp com.android.car.ui.plugin.PluginVersionProviderImpl . Tất cả các plugin phải bao gồm một lớp có tên và tên gói chính xác này. Lớp này phải có hàm tạo mặc định và triển khai giao diện PluginVersionProviderOEMV1 .

Plugin CarUi phải hoạt động với các ứng dụng cũ hơn hoặc mới hơn plugin. Để tạo điều kiện thuận lợi cho việc này, tất cả các API plugin đều được tạo phiên bản bằng V# ở cuối tên lớp của chúng. Nếu một phiên bản mới của thư viện Car UI được phát hành với các tính năng mới thì chúng là một phần của phiên bản V2 của thành phần này. Thư viện Car UI cố gắng hết sức để làm cho các tính năng mới hoạt động trong phạm vi của thành phần plugin cũ hơn. Ví dụ: bằng cách chuyển đổi một loại nút mới trên thanh công cụ thành MenuItems .

Tuy nhiên, ứng dụng có phiên bản cũ hơn của thư viện Car UI không thể thích ứng với plugin mới được viết dựa trên các API mới hơn. Để giải quyết vấn đề này, chúng tôi cho phép các plugin trả về các cách triển khai khác nhau dựa trên phiên bản API OEM được ứng dụng hỗ trợ.

PluginVersionProviderOEMV1 có một phương thức trong đó:

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

Phương thức này trả về một đối tượng triển khai phiên bản cao nhất của PluginFactoryOEMV# được plugin hỗ trợ, trong khi vẫn nhỏ hơn hoặc bằng maxVersion . Nếu một plugin không triển khai PluginFactory cũ như vậy thì nó có thể trả về null , trong trường hợp đó việc triển khai các thành phần CarUi được liên kết tĩnh sẽ được sử dụng.

Để duy trì khả năng tương thích ngược với các ứng dụng được biên dịch dựa trên các phiên bản cũ hơn của thư viện Car Ui tĩnh, bạn nên hỗ trợ các giá trị maxVersion từ 2, 5 trở lên trong quá trình triển khai lớp PluginVersionProvider của plugin. Phiên bản 1, 3 và 4 không được hỗ trợ. Để biết thêm thông tin, hãy xem PluginVersionProviderImpl .

PluginFactory là giao diện tạo ra tất cả các thành phần CarUi khác. Nó cũng xác định phiên bản giao diện nào sẽ được sử dụng. Nếu plugin không tìm cách triển khai bất kỳ thành phần nào trong số này, thì nó có thể trả về null trong hàm tạo của chúng (ngoại trừ thanh công cụ có hàm customizesBaseLayout() riêng biệt).

pluginFactory giới hạn phiên bản nào của thành phần CarUi có thể được sử dụng cùng nhau. Ví dụ: sẽ không bao giờ có pluginFactory có thể tạo phiên bản 100 của Toolbar và cả phiên bản 1 của RecyclerView , vì sẽ có rất ít đảm bảo rằng nhiều phiên bản thành phần khác nhau sẽ hoạt động cùng nhau. Để sử dụng thanh công cụ phiên bản 100, các nhà phát triển phải cung cấp triển khai phiên bản pluginFactory để tạo phiên bản thanh công cụ 100, sau đó giới hạn các tùy chọn trên phiên bản của các thành phần khác có thể được tạo. Phiên bản của các thành phần khác có thể không bằng nhau, ví dụ: pluginFactoryOEMV100 có thể tạo ToolbarControllerOEMV100RecyclerViewOEMV70 .

Thanh công cụ

Bố cục cơ sở

Thanh công cụ và "bố cục cơ sở" có liên quan rất chặt chẽ với nhau, do đó chức năng tạo thanh công cụ được gọi là installBaseLayoutAround . Bố cục cơ sở là một khái niệm cho phép thanh công cụ được đặt ở bất kỳ đâu xung quanh nội dung của ứng dụng, cho phép thanh công cụ ở trên cùng/dưới cùng của ứng dụng, theo chiều dọc dọc theo các cạnh hoặc thậm chí là thanh công cụ hình tròn bao quanh toàn bộ ứng dụng. Điều này được thực hiện bằng cách chuyển chế độ xem tới installBaseLayoutAround để bao quanh bố cục thanh công cụ/cơ sở.

Plugin phải sử dụng chế độ xem được cung cấp, tách nó khỏi chế độ xem gốc, tăng cường bố cục của chính plugin trong cùng một chỉ mục của chế độ xem gốc và có cùng LayoutParams như chế độ xem vừa được tách ra, sau đó gắn lại chế độ xem ở đâu đó bên trong bố cục vừa thổi phồng. Bố cục tăng cao sẽ chứa thanh công cụ nếu ứng dụng yêu cầu.

Ứng dụng có thể yêu cầu bố cục cơ sở mà không cần thanh công cụ. Nếu đúng như vậy, installBaseLayoutAround sẽ trả về giá trị rỗng. Đối với hầu hết các plugin, đó là tất cả những gì cần phải xảy ra, nhưng nếu tác giả plugin muốn áp dụng, chẳng hạn như trang trí xung quanh mép ứng dụng, thì điều đó vẫn có thể được thực hiện với bố cục cơ sở. Những trang trí này đặc biệt hữu ích cho các thiết bị có màn hình không phải hình chữ nhật, vì chúng có thể đẩy ứng dụng vào không gian hình chữ nhật và thêm các chuyển tiếp rõ ràng vào không gian không phải hình chữ nhật.

installBaseLayoutAround cũng được thông qua Consumer<InsetsOEMV1> . Người tiêu dùng này có thể được sử dụng để thông báo với ứng dụng rằng plugin đang che một phần nội dung của ứng dụng (bằng thanh công cụ hoặc cách khác). Sau đó, ứng dụng sẽ biết cách tiếp tục vẽ trong không gian này nhưng loại bỏ mọi thành phần quan trọng mà người dùng có thể tương tác với nó. Hiệu ứng này được sử dụng trong thiết kế tham chiếu của chúng tôi để làm cho thanh công cụ trở nên trong suốt và có các danh sách cuộn bên dưới nó. Nếu tính năng này không được triển khai, mục đầu tiên trong danh sách sẽ bị kẹt bên dưới thanh công cụ và không thể nhấp vào được. Nếu hiệu ứng này không cần thiết, plugin có thể bỏ qua tệp Consumer.

Cuộn nội dung bên dưới thanh công cụ Hình 2. Nội dung cuộn bên dưới thanh công cụ

Từ góc độ của ứng dụng, khi plugin gửi các phần lồng ghép mới, nó sẽ nhận chúng từ bất kỳ hoạt động hoặc phân đoạn nào triển khai InsetsChangedListener . Nếu một hoạt động hoặc đoạn không triển khai InsetsChangedListener thì thư viện Car Ui sẽ xử lý các phần lồng ghép theo mặc định bằng cách áp dụng các phần lồng ghép dưới dạng phần đệm cho Activity hoặc FragmentActivity có chứa đoạn. Theo mặc định, thư viện không áp dụng phần lồng ghép cho các đoạn. Dưới đây là đoạn mã mẫu của quá trình triển khai áp dụng phần lồng ghép dưới dạng phần đệm trên RecyclerView trong ứng dụng:

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());
  }
}

Cuối cùng, plugin được cung cấp gợi ý fullscreen , được sử dụng để cho biết liệu chế độ xem cần được bao bọc sẽ chiếm toàn bộ ứng dụng hay chỉ một phần nhỏ. Điều này có thể được sử dụng để tránh áp dụng một số trang trí dọc theo cạnh mà chỉ có ý nghĩa nếu chúng xuất hiện dọc theo cạnh của toàn bộ màn hình. Ứng dụng mẫu sử dụng bố cục cơ bản không toàn màn hình là Cài đặt, trong đó mỗi khung của bố cục khung kép có thanh công cụ riêng.

Vì dự kiến installBaseLayoutAround sẽ trả về null khi toolbarEnabledfalse , nên để plugin cho biết rằng nó không muốn tùy chỉnh bố cục cơ sở, nó phải trả về false từ customizesBaseLayout .

Bố cục cơ sở phải chứa FocusParkingViewFocusArea để hỗ trợ đầy đủ các điều khiển xoay. Những chế độ xem này có thể bị bỏ qua trên các thiết bị không hỗ trợ quay. FocusParkingView/FocusAreas được triển khai trong thư viện CarUi tĩnh, do đó, setRotaryFactories được sử dụng để cung cấp cho các nhà máy tạo chế độ xem từ ngữ cảnh.

Ngữ cảnh được sử dụng để tạo chế độ xem Tiêu điểm phải là ngữ cảnh nguồn chứ không phải ngữ cảnh của plugin. FocusParkingView phải ở mức gần nhất với chế độ xem đầu tiên trong cây một cách hợp lý nhất có thể, vì đó là chế độ xem được tập trung khi người dùng không nhìn thấy tiêu điểm. FocusArea phải bao bọc thanh công cụ trong bố cục cơ sở để cho biết rằng đó là vùng dịch chuyển xoay. Nếu FocusArea không được cung cấp, người dùng không thể điều hướng đến bất kỳ nút nào trên thanh công cụ bằng bộ điều khiển xoay.

Bộ điều khiển thanh công cụ

ToolbarController thực tế được trả về sẽ dễ triển khai hơn nhiều so với bố cục cơ sở. Công việc của nó là lấy thông tin được chuyển đến người thiết lập và hiển thị nó trong bố cục cơ sở. Xem Javadoc để biết thông tin về hầu hết các phương pháp. Một số phương pháp phức tạp hơn sẽ được thảo luận dưới đây.

getImeSearchInterface được sử dụng để hiển thị kết quả tìm kiếm trong cửa sổ IME (bàn phím). Điều này có thể hữu ích để hiển thị/tạo hoạt ảnh cho kết quả tìm kiếm bên cạnh bàn phím, ví dụ: nếu bàn phím chỉ chiếm một nửa màn hình. Hầu hết chức năng được triển khai trong thư viện CarUi tĩnh, giao diện tìm kiếm trong plugin chỉ cung cấp các phương thức cho thư viện tĩnh để nhận các lệnh gọi lại TextViewonPrivateIMECommand . Để hỗ trợ điều này, plugin nên sử dụng lớp con TextView ghi đè onPrivateIMECommand và chuyển lệnh gọi đến trình nghe được cung cấp dưới dạng TextView của thanh tìm kiếm.

setMenuItems chỉ hiển thị MenuItems trên màn hình, nhưng nó sẽ được gọi thường xuyên một cách đáng ngạc nhiên. Vì API plugin cho MenuItems là bất biến nên bất cứ khi nào MenuItem được thay đổi, một lệnh gọi setMenuItems hoàn toàn mới sẽ xảy ra. Điều này có thể xảy ra đối với một điều gì đó tầm thường như người dùng nhấp vào nút chuyển MenuItem và thao tác nhấp chuột đó khiến nút chuyển chuyển đổi. Do đó, vì cả lý do hiệu suất và hoạt ảnh, chúng tôi khuyến khích tính toán sự khác biệt giữa danh sách MenuItems cũ và mới và chỉ cập nhật các chế độ xem thực sự đã thay đổi. MenuItems cung cấp một trường key có thể trợ giúp việc này vì khóa phải giống nhau trên các lệnh gọi khác nhau tới setMenuItems cho cùng một MenuItem.

AppStyledView

AppStyledView là vùng chứa cho một chế độ xem hoàn toàn không được tùy chỉnh. Nó có thể được sử dụng để cung cấp đường viền xung quanh chế độ xem đó, làm cho nó nổi bật so với phần còn lại của ứng dụng và cho người dùng biết rằng đây là một loại giao diện khác. Chế độ xem được bao bọc bởi AppStyledView được cung cấp trong setContent . AppStyledView cũng có thể có nút quay lại hoặc đóng theo yêu cầu của ứng dụng.

AppStyledView không ngay lập tức chèn các chế độ xem của nó vào hệ thống phân cấp chế độ xem như installBaseLayoutAround , thay vào đó, nó chỉ trả chế độ xem của nó về thư viện tĩnh thông qua getView , sau đó thực hiện thao tác chèn. Vị trí và kích thước của AppStyledView cũng có thể được kiểm soát bằng cách triển khai getDialogWindowLayoutParam .

Bối cảnh

Plugin phải cẩn thận khi sử dụng Ngữ cảnh, vì có cả ngữ cảnh plugin và ngữ cảnh "nguồn". Ngữ cảnh plugin được đưa ra làm đối số cho getPluginFactory và là ngữ cảnh duy nhất có tài nguyên của plugin trong đó. Điều này có nghĩa đây là ngữ cảnh duy nhất có thể được sử dụng để tăng cường bố cục trong plugin.

Tuy nhiên, bối cảnh plugin có thể không được đặt cấu hình chính xác trên đó. Để có được cấu hình chính xác, chúng tôi cung cấp ngữ cảnh nguồn trong các phương thức tạo thành phần. Ngữ cảnh nguồn thường là một hoạt động nhưng trong một số trường hợp cũng có thể là Dịch vụ hoặc thành phần Android khác. Để sử dụng cấu hình từ ngữ cảnh nguồn với các tài nguyên từ ngữ cảnh plugin, ngữ cảnh mới phải được tạo bằng cách sử dụng createConfigurationContext . Nếu không sử dụng cấu hình chính xác thì sẽ vi phạm chế độ nghiêm ngặt của Android và các chế độ xem tăng cao có thể không có thứ nguyên chính xác.

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

Thay đổi chế độ

Một số plugin có thể hỗ trợ nhiều chế độ cho các thành phần của chúng, chẳng hạn như chế độ thể thao hoặc chế độ sinh thái trông khác biệt về mặt trực quan. Không có hỗ trợ tích hợp nào cho chức năng như vậy trong CarUi, nhưng không có gì ngăn cản plugin triển khai chức năng đó hoàn toàn trong nội bộ. Plugin có thể theo dõi bất kỳ điều kiện nào nó muốn để tìm ra thời điểm chuyển đổi chế độ, chẳng hạn như nghe chương trình phát sóng. Plugin không thể kích hoạt thay đổi cấu hình để thay đổi chế độ, nhưng dù sao thì cũng không nên dựa vào các thay đổi cấu hình vì việc cập nhật thủ công giao diện của từng thành phần sẽ mượt mà hơn đối với người dùng và cũng cho phép thực hiện các chuyển đổi không thể thực hiện được khi thay đổi cấu hình.

Jetpack Soạn

Bạn có thể triển khai plugin bằng Jetpack Compose, nhưng đây là tính năng cấp độ alpha và không được coi là ổn định.

Các plugin có thể sử dụng ComposeView để tạo một bề mặt hỗ trợ Compose để hiển thị. ComposeView này sẽ là nội dung được trả về ứng dụng từ phương thức getView trong thành phần.

Một vấn đề lớn khi sử dụng ComposeView là nó đặt các thẻ trên chế độ xem gốc trong bố cục để lưu trữ các biến chung được chia sẻ trên các ComposeView khác nhau trong hệ thống phân cấp. Vì id tài nguyên của plugin không được đặt tên riêng biệt với id tài nguyên của ứng dụng nên điều này có thể gây ra xung đột khi cả ứng dụng và plugin đều đặt thẻ trên cùng một chế độ xem. ComposeViewWithLifecycle tùy chỉnh giúp di chuyển các biến toàn cục này xuống ComposeView được cung cấp bên dưới. Một lần nữa, điều này không nên được coi là ổn định.

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)
//  }
}