Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Hermes] Android integration #410

Merged
merged 122 commits into from
Aug 4, 2024
Merged

[Hermes] Android integration #410

merged 122 commits into from
Aug 4, 2024

Conversation

sugarmanz
Copy link
Member

@sugarmanz sugarmanz commented Jul 2, 2024

Rationale

By default, the Android Player consumes com.intuit.playerui:j2v8-android to provide the JS runtime integration since it is reasonably fast and small compared to some of the alternatives (6-7 MB per architecture). However, as the goal with the runtime abstraction was to enable any JS runtime to be swapped out at will, we want to officially support runtimes that bring value to the platform. The Hermes JS runtime created for React Native showed promising size improvements along with a potential runtime performance optimization for pre-compiled JS. Since we also leverage Bazel as our build tool, this empowers us to more easily bring in complex build dependencies as a patternized part of our build. Integrating with this runtime has been on our backlog for several years, and it's great to finally be able to properly consider it.

Results

Shown below is a screenshot comparing the APK size of the //android/demo application, with some notes beneath:

image

  • The overall size of the app doesn’t represent just Player, as it includes all the components required for our demo app as well.
  • The breakdown includes raw size vs download size, as well as multiple CPU architectures. We really only care about the download size for a single architecture (as long a the app is published as an AAB — if it’s not, it’ll include all CPU architectures, but that would be a further optimization to be done on the app side).
  • The largest part of Player is the native engine that runs the core Player code. The existing engine, J2V8 being 6-7 MB per architecture. Hermes, plus the native components we need to utilize it, is reported as 1.4-1.8 MB! With arm64 being the most popular architectures, this is a drop from 7 -> 1.6 MB. Again, these numbers might fluctuate with changes to the Hermes integration, but I don’t expect them to change significantly.
  • The other parts of Player is the actual code, both core and JVM classes. Not accounting for plugins (as that varies by the needs of the integration), they come out to about 675 KB, resulting in a total size for Android Player of about 2.3 MB.

This PR doesn't serve to publish Hermes compatible runtime integrations for non-Android environments, however, it does build Hermes for the host platform to run our JVM HeadlessPlayer tests against. J2V8 seemed to have a leg up with regard to desktop performance (without bytecode optimization), but fortunately, Hermes built for Android is different in a few ways and ends up proving to be relatively flat runtime performance in comparison, if not slightly better. More performance testing to come with bytecode optimization.

Other improvements include:

  • Better ES6 compatibility
  • Better cross-JNI/JSI exception handling (thanks FBJNI and source map support)

Next Steps

The initial integration proved to provide a substantial size decrease, while offering a relatively flat, if not slightly better, runtime performance (on Android). This is a great start, but there are more improvements we can make in future iterations, in order of complexity:

  • Dev docs
  • Assume as default runtime for Android Player
  • Hermes Bytecode compilation
  • Publish Hermes integration for desktop JVM environments
  • Consolidate JSI definition with Player runtime abstraction
    • Enables much more common parts of the runtime serialization layer 🎉 along with solving a very longstanding TODO
  • Static Hermes

How to review this PR

This is a relatively large PR that includes multiple languages and complex build setup. There will be more comprehensive development docs to come, but here are the key parts to look at:

Bazel setup

Our Bazel setup uses different strategies for integrating Hermes for Android and host machines. For host, it's simple enough to compile Hermes directly and consume for running headless tests. For Android, we have initially used a prebuilt Hermes from React Native - not ideal, but helps us get integrated a bit quicker. We'll want to plan on configuring proper x-compilation for Android so we can have finer grain control of the Hermes build configuration and optimize for our use case. You can see which artifacts are used for which platforms below Android here and host here.

JSI JNI (JavaScript Interface Java Native Interface)

Hermes is built to be a JSI compliant runtime, meaning we can build out our integration with respect to the JSI definition. Some more on this in #395 PR description. More that's not covered in that description includes:

JSI Serialization

The Player runtime abstraction is built on the kotlinx.serialization, and thus we need an encoder and decoder to support the new JSI types from above. Mostly similar to the other runtimes, leading towards a greater idea of how we can lean into JSI and consolidate the implementations a bit more. Some additional things to note below:

//jvm/core Code Changes

Most of the changes in here are small tweaks to fix edge cases I encountered while testing, but there might be some new enhancements to better serve runtime integrations, like Runtime.executeRaw and ScriptContext.sourceMap.

Change Type (required)

Indicate the type of change your pull request is:

  • patch
  • minor
  • major

Does your PR have any documentation updates?

  • Updated docs
  • No Update needed
  • Unable to update docs

Release Notes

Initial integration with the Hermes JavaScript runtime. This shows a tremendous size improvement over the existing J2V8 integration of ~70% (7.6 MB -> 2.3 MB, architecture dependent).

Opt-in

For now, the default runtime integration provided by the Android Player will still be com.intuit.playerui:j2v8-android, but Hermes can be opted in manually by excluding the J2V8 transitive dependency and including the Hermes artifact:

dependencies {
    // Android Player dependency
    implementation("com.intuit.playerui", "android", PLAYER_VERSION) {
        // J2V8 included for release versions
        exclude(group = "com.intuit.playerui", module = "j2v8-android")
        // Debuggable J2V8 included for canary versions
        exclude(group = "com.intuit.playerui", module = "j2v8-android-debug")
    }
    // Override with Hermes runtime
    implementation("com.intuit.playerui", "hermes-android", PLAYER_VERSION)
}

// Exclude J2V8 transitive dependency for all configurations in this module
configurations { 
    all {
        exclude(group = "com.intuit.playerui", module = "j2v8-android")
        // Debuggable J2V8 included for canary versions
        exclude(group = "com.intuit.playerui", module = "j2v8-android-debug")
    }
}

Tip

If your application includes dependencies that may transitively depend on com.intuit.playerui:android, you would likely need to ensure the default runtime is transitively excluded from those as well, either manually or as a global strategy.

The AndroidPlayer will pick the first runtime it finds on the classpath - you can at least verify which runtime was used for the Player with a new log: Player created using $runtime. But that won't tell you for certain if the other runtimes were successfully excluded. You'll need to examine your APK, or your apps dependency tree, to tell for sure that redundant runtimes aren't unintentionally included.

Most of the setup for this integration is done simply by including the right dependency (and excluding the wrong one), however, the hermes-android integration also relies on the SoLoader for loading the native libraries. All that's needed is to initialize the SoLoader (should be on your classpath with the hermes-android dependency) with an Android Context somewhere before you use the AndroidPlayer, potentially in your activities onCreate:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    SoLoader.init(this, false)
    // ...
}
📦 Published PR as canary version: 0.8.0--canary.410.16288

Try this version out locally by upgrading relevant packages to 0.8.0--canary.410.16288

Version

Published prerelease version: 0.9.0-next.0

Changelog

Release Notes

[Hermes] Android integration (#410)

Initial integration with the Hermes JavaScript runtime. This shows a tremendous size improvement over the existing J2V8 integration of ~70% (7.6 MB -> 2.3 MB, architecture dependent).

Opt-in

For now, the default runtime integration provided by the Android Player will still be com.intuit.playerui:j2v8-android, but Hermes can be opted in manually by excluding the J2V8 transitive dependency and including the Hermes artifact:

dependencies {
    // Android Player dependency
    implementation("com.intuit.playerui", "android", PLAYER_VERSION) {
        // J2V8 included for release versions
        exclude(group = "com.intuit.playerui", module = "j2v8-android")
        // Debuggable J2V8 included for canary versions
        exclude(group = "com.intuit.playerui", module = "j2v8-android-debug")
    }
    // Override with Hermes runtime
    implementation("com.intuit.playerui", "hermes-android", PLAYER_VERSION)
}

[!TIP]
If your application includes dependencies that may transitively depend on com.intuit.playerui:android, you would likely need to ensure the default runtime is transitively excluded from those as well, either manually or as a global strategy.

The AndroidPlayer will pick the first runtime it finds on the classpath - you can at least verify which runtime was used for the Player with a new log: Player created using $runtime. But that won't tell you for certain if the other runtimes were successfully excluded. You'll need to examine your APK, or your apps dependency tree, to tell for sure that redundant runtimes aren't unintentionally included.

Most of the setup for this integration is done simply by including the right dependency (and excluding the wrong one), however, the hermes-android integration also relies on the SoLoader for loading the native libraries. All that's needed is to initialize the SoLoader (should be on your classpath with the hermes-android dependency) with an Android Context somewhere before you use the AndroidPlayer, potentially in your activities onCreate:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    SoLoader.init(this, false)
    // ...
}

🚀 Enhancement

Authors: 2

@sugarmanz sugarmanz added the minor Increment the minor version when merged label Jul 2, 2024
@sugarmanz sugarmanz marked this pull request as ready for review July 2, 2024 20:14
@sugarmanz sugarmanz force-pushed the hermes/android branch 2 times, most recently from 051a078 to 2b42f7a Compare July 8, 2024 10:12
@sugarmanz sugarmanz force-pushed the hermes/staging branch 2 times, most recently from 9203d45 to 06b3e7b Compare July 9, 2024 19:34
@sugarmanz
Copy link
Member Author

/canary

3 similar comments
@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

Copy link

codecov bot commented Jul 10, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 91.94%. Comparing base (9c4cd8c) to head (a728ef6).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #410   +/-   ##
=======================================
  Coverage   91.94%   91.94%           
=======================================
  Files         340      340           
  Lines       26838    26838           
  Branches     1946     1946           
=======================================
+ Hits        24675    24677    +2     
+ Misses       2149     2147    -2     
  Partials       14       14           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

2 similar comments
@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

1 similar comment
@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

/canary

@sugarmanz sugarmanz changed the base branch from hermes/staging to main August 1, 2024 16:12
@sugarmanz
Copy link
Member Author

/canary

@sugarmanz
Copy link
Member Author

Contains the work from #395 & #399.

@@ -16,7 +16,7 @@ orbs:
executors:
base:
docker:
- image: docker.io/playerui/bazel-docker
- image: docker.io/playerui/bazel-docker:11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we merge the docker changes in before merging this so we're not targeting a specific image?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can! I was avoiding merging that PR until the release was done to make sure it wouldn't accidentally break CI (does make a strong case for tagging our images per release and always targeting a tag).

},
)

# TODO: Enable platform support for detecting Android OS as well as cpu
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will be something we'll have better support for w/ Bazel 7

@@ -120,8 +125,7 @@ use_repo(remote_android_extensions, "android_gmaven_r8")
bazel_dep(name = "rules_jvm_external")
git_override(
module_name = "rules_jvm_external",
# bazel-6 branch
commit = "44f4355b2dbe0d6fd73d690ad66bf5744d482a29",
commit = "73b63ba801f14d1bde7807994cc8c15db226ceec",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue encountered when packaging large binaries:
sugarmanz/rules_jvm_external@73b63ba

@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
/** Generic exception for any errors encountered in the scope of the [Player] */
@Suppress("SERIALIZER_TYPE_INCOMPATIBLE")
@Serializable(ThrowableSerializer::class)
public open class PlayerException(message: String, cause: Throwable? = null) : Exception(message, cause)
public open class PlayerException @JvmOverloads constructor(message: String, cause: Throwable? = null) : Exception(message, cause)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Necessary to make constructor(message: String) available to native land

@@ -16,7 +16,7 @@ orbs:
executors:
base:
docker:
- image: docker.io/playerui/bazel-docker
- image: docker.io/playerui/bazel-docker:11
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can! I was avoiding merging that PR until the release was done to make sure it wouldn't accidentally break CI (does make a strong case for tagging our images per release and always targeting a tag).

@@ -237,6 +237,7 @@ jobs:

- run: |
bazel test --config=ci -- $(bazel query 'kind(".*_test", //...) except filter("ios|swiftui", //...)') -//android/demo:android_instrumentation_test
bazel test --config=ci --//android/player:runtime=hermes -- $(bazel query 'kind(".*_test", //...) except filter("ios|swiftui", //...)') -//android/demo:android_instrumentation_test
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This, plus the one below ensure we re-run all relevant tests (that depend on //android/player:runtime) against the Hermes runtime. It'll return the cached result for all tests that don't use that config flag, directly or transitively.

Comment on lines +26 to +28
@ExperimentalPlayerApi
public fun executeRaw(script: String): Value =
throw UnsupportedOperationException("This experimental method is not implemented for ${this::class.simpleName}")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New experimental API to reduce serialization cost of executing JS.

@@ -59,4 +64,5 @@ public inline fun <reified T> Runtime<*>.serialize(serializer: SerializationStra
public data class ScriptContext(
val script: String,
val id: String,
val sourceMap: String? = null,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Powers source map support

# Conflicts:
#	MODULE.bazel
#	WORKSPACE.bzlmod
@KetanReddy KetanReddy merged commit 73d3af9 into main Aug 4, 2024
11 checks passed
@KetanReddy KetanReddy deleted the hermes/android branch August 4, 2024 20:31
@KetanReddy
Copy link
Member

@sugarmanz with this merged can we close #410 and #399?

@sugarmanz
Copy link
Member Author

@sugarmanz with this merged can we close #410 and #399?

Yep! I'll do that in a min!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
minor Increment the minor version when merged
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants