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

custom fonts support The Android Way #24595

Closed
wants to merge 13 commits into from

Conversation

dulmandakh
Copy link
Contributor

Summary

In #23865, RN introduced support for custom fonts the Android Way. But it introduced performance regression because it'll lookup for a font using getIdentifier() every time fontFamily changed. This PR fixes regression by requiring custom fonts to be listed in fonts array, and populating mTypeCache at first use using the list.

Changelog

[Android] [Changed] - Require custom fonts to list in fonts array. Fixes performance regression.

Test Plan

CI is green and RNTester app works as expected.

@facebook-github-bot facebook-github-bot added CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Contributor A React Native contributor. labels Apr 25, 2019
@react-native-bot react-native-bot added Impact: Regression Describes a behavior that used to work on a prior release, but stopped working recently. Includes Changelog labels Apr 25, 2019
@dulmandakh
Copy link
Contributor Author

dulmandakh commented Apr 25, 2019 via email

@fkgozali
Copy link
Contributor

This is only one for an instance, and it's singleton.

It's still gonna be in the RN startup path, so it would still affect startup perf.

IMHO, asking developers to write a custom provider code just to have a custom font is a
bad DX.

You can always provide a default provider, and have the ability for apps to change or disable the default provider.

@cpojer
Copy link
Contributor

cpojer commented Apr 25, 2019

@dulmandakh to clarify, calling this method in RN's startup path means that it will literally add 10s of ms to the startup. FB scale is crazy.

@dulmandakh
Copy link
Contributor Author

ok, i'll figure out a solution for it, thank you.

@dulmandakh dulmandakh changed the title fix performance regression of custom fonts custom fonts support The Android Way Apr 26, 2019
@dulmandakh
Copy link
Contributor Author

dulmandakh commented Apr 26, 2019

@fkgozali changed it to look into meta-data in AndroidManifest.xml, thus not lookup resources by name. It requires some effort from developers, but it's much easier and clean way to add custom fonts on Android.

for (int i = 0; i < fonts.length(); i++) {
int fontId = fonts.getResourceId(i, 0);
if (fontId != 0) {
Typeface font = ResourcesCompat.getFont(context, fontId);
Copy link

Choose a reason for hiding this comment

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

How expensive is loading Typeface instances for every single font of the app into the cache and calling resources.getResourceEntryName at startup?

If it is also taking too long (for FB scale), although it's verbose you might do something like:

<array name="fonts">
    
    <item>srisakdi</item>
    <item>@font/srisakdi</item>
    
    <item>anotherfont</item>
    <item>@font/anotherfont</item>

</array>

This way you can iterate the array in increments of 2, and get away with only caching fontFamily->fontId mapping at startup, and only inflate fontId->Typeface still lazily.

Copy link

Choose a reason for hiding this comment

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

Also this should allow font file names to not strictly match font family names, ex:

<item>Open Sans</item>
<item>@font/open_sans</item>

Native devs will be more familiar underscored naming R.font.open_sans whereas web developers will be more familiar with human readable naming fontFamily: "Open Sans".

This will allow sharing cross platform styling code with iOS, where font family name is already retrieved from font file content, not from file names.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried to mimic iOS feature, where we only list font files, but also make typo-proof. Also I couldn't find anything about performance impact of getResourceEntryName, and hope it won't have impact on performance.

Copy link

Choose a reason for hiding this comment

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

iOS works a bit differently, it reads font family name from the file content, Android in your implementation reads from the file name.
https://developer.apple.com/documentation/uikit/text_display_and_fonts/adding_a_custom_font_to_your_app
https://stackoverflow.com/questions/16788330/how-do-i-get-the-font-name-from-an-otf-or-ttf-file

IMO this will force developers to write strange JS code like:
{fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'}
I dont know why typo-proof is such an important thing, you only need to match with what you use in JS code, you could make a mistake in font file name too.


Just looking at getResourceEntryName implementation in AOSP repo, it's implemented in C++ so you will at least jump between JNI bridge for each call.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I found that Android doesn't allow to use font family name, but resource filename without extension. So difference is inevitable.

With this change we can use various font weights like medium easily. Currently, you have to write something like which makes text style sharing difficult.

{
   fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans_medium'
   fontWeight: Platform.OS == 'ios' ? '500' : 'normal'
}

once PR lands you will write

{
   fontFamily: Platform.OS == 'ios' ? 'Open Sans' : 'open_sans'
   fontWeight: '500'
}

Copy link

@hey99xx hey99xx Apr 26, 2019

Choose a reason for hiding this comment

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

You can currently name your file "Open Sans.ttf", "Open Sans_bold.ttf"... etc though, so the code can already be cross platform fontFamily: "Open Sans". Can you leave a space in these font resource file names and reuse that resource in Java? IMO it's not good practice to enforce android resource naming conventions into JS land.

try {
ApplicationInfo app = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
Bundle bundle = app.metaData;
int fontsId = bundle.getInt("rn_fonts", 0);
Copy link

@hey99xx hey99xx Apr 26, 2019

Choose a reason for hiding this comment

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

Can this be namespaced like com.facebook.react.font.resources instead of rn_fonts?

@dulmandakh dulmandakh requested a review from fkgozali April 26, 2019 12:02
Bundle bundle = app.metaData;
int fontsId = bundle.getInt("rn_fonts", 0);
if (fontsId != 0) {
Resources resources = context.getResources();
Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, I'm not sure if accessing anything dynamically from Resources is fast, because it needs to crack them open from the APK.

Can we make this not do any resource lookup by default and only perform this lookup if the hosting app configures it? You don't really need to change it to use AndroidManifest, using font resource is fine, but I'd like:

  • no lookup by default
  • do custom lookup with any impl for now only if the hosting app enables it explicitly

Copy link

Choose a reason for hiding this comment

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

I think @dulmandakh is trying to find a way where non-native developers don't have to touch java code to customize these things, that's why he switched to using AndroidManifest that's supposed to be a cheaper alternative to resource lookups.

If an app like FB does not provide "rn_fonts" in AndroidManifest.xml, there will never be any look up (besides looking to AndroidManifest itself), isn't that sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

got it. I'll figure out another solution :D

@dulmandakh
Copy link
Contributor Author

@fkgozali another implementation which addresses your concerns, but also requires less work from developers.

Copy link
Contributor

@fkgozali fkgozali left a comment

Choose a reason for hiding this comment

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

Mostly nits

import androidx.annotation.Nullable;
import androidx.core.content.res.ResourcesCompat;


Copy link
Contributor

Choose a reason for hiding this comment

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

remove extra newline

@@ -37,9 +40,12 @@
private static ReactFontManager sReactFontManagerInstance;

private Map<String, FontFamily> mFontCache;
private Map<String, Typeface> mTypeCache;
private boolean mTypeCacheLoaded = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think a better set of names is:

  • mCustomFontCache
  • mHasCustomFont

Copy link
Contributor

Choose a reason for hiding this comment

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

or:

  • mCustomTypefaceCache
  • mHasCustomTypeface

@@ -71,6 +83,15 @@ public static ReactFontManager getInstance() {
return typeface;
}

public void loadFont(Context context, int fontId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

should rename to addCustomFont() to make it clearer

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you also add comment for how to call this method? E.g. what you did for RNTester. Basically document how one can install a custom font support in their apps.

public void loadFont(Context context, int fontId) {
Typeface font = ResourcesCompat.getFont(context, fontId);
if (font != null) {
String fontFamily = context.getResources().getResourceEntryName(fontId);
Copy link
Contributor

Choose a reason for hiding this comment

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

This won't affect FB with this PR, but I wonder if this is efficient in practice. You don't have to address it in this PR, but I think allowing apps to provide a custom "typeface resolution" function can allow them to address slow lookup. For instance, if one day FB wants to use custom fonts, I'd actually implement the resolver via a switch statement, not via asking getResourceEntryName(). Most apps probably don't need such optimization, but having an option to do optimized thing is good.

Copy link
Contributor

Choose a reason for hiding this comment

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

Most apps probably don't need such optimization, but having an option to do optimized thing is good.

An example pseudo solution:

public interface ReactTypefaceResolver {
  public String resolveTypefaceName(int fontId);
}

Then introduce a method in ReactFontManager:

public void setCustomTypefaceResolver(ReactTypefaceResolver resolver) {
  mCustomTypefaceResolver = resolver;
}

Then you can just use mCustomTypefaceResolver != null as the flag, no need to have the boolean flag you had above. Then you can just call it inside loadFont().

@dulmandakh
Copy link
Contributor Author

dulmandakh commented Apr 30, 2019 via email

@dulmandakh
Copy link
Contributor Author

@fkgozali renamed variables and removed resource lookup code, therefore resolved feature parity with iOS because we can assign any font family name to a font. Thank you

style
);
}

Typeface typeface = fontFamily.getTypeface(style);
if (typeface == null) {
typeface = createTypeface(fontFamilyName, style, assetManager);
Copy link

Choose a reason for hiding this comment

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

Is there value caching invidual styled Typeface (normal/italic/bold/boldItalic) instances here like it's done for fonts in assets? Besides that all LGTM.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

to use styles or weights you have to define fonts using XML.

@dulmandakh dulmandakh requested a review from fkgozali April 30, 2019 06:23
@@ -60,6 +65,13 @@ public static ReactFontManager getInstance() {
mFontCache.put(fontFamilyName, fontFamily);
}

if(mHasCustomTypeface && mCustomTypefaceCache.containsKey(fontFamilyName)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: add space after if

@@ -37,9 +40,12 @@
private static ReactFontManager sReactFontManagerInstance;

private Map<String, FontFamily> mFontCache;
private Map<String, Typeface> mCustomTypefaceCache;
private boolean mHasCustomTypeface = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically you could just make mCustomTypefaceCache @Nullable, then default to null. If someone adds a custom typeface, you initialize it as a new HashMap, then you don't need mHasCustomTypeface boolean. You can just check if mCustomTypefaceCache != null

Copy link
Contributor Author

@dulmandakh dulmandakh May 2, 2019

Choose a reason for hiding this comment

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

Yes, my first version was like that but changed it to use booleans just before the commit because this seemed cleaner.

Copy link
Contributor

Choose a reason for hiding this comment

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

from @mdvacca:

let's add final to these variables

Copy link
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

@fkgozali has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

@dulmandakh
Copy link
Contributor Author

@hramos it's not merged yet. Could you please investigate it. Thank you

@fkgozali
Copy link
Contributor

fkgozali commented May 6, 2019

it's not merged yet. Could you please investigate it. Thank you

No one kicked off the merging. I still need to run the simulated perf test to verify there's no regression.

@@ -37,9 +40,12 @@
private static ReactFontManager sReactFontManagerInstance;

private Map<String, FontFamily> mFontCache;
private Map<String, Typeface> mCustomTypefaceCache;
private boolean mHasCustomTypeface = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

from @mdvacca:

let's add final to these variables

*
* ReactFontManager.getInstance().addCustomFont(this, "Srisakdi", R.font.srisakdi);
*/
public void addCustomFont(Context context, @NonNull String fontFamily, int fontId) {
Copy link
Contributor

Choose a reason for hiding this comment

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

from @mdvacca:

should we add @NonNull to context?

if (font != null) {
mCustomTypefaceCache.put(fontFamily, font);
}
mHasCustomTypeface = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

From @mdvacca:

doesn't this need to be inside the if (font != null) {
also, why not to just remove this variable and create a private method:

private boolean hasCustomTypeface()  {
    return mCustomTypefaceCache.isEmpty();
}

or maybe we don't even need this variable, it is just preventing a call to Map.containsKey?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we pass resource id to this method, but that ID might not be a font, but anything. So font != null is making sure that we have a font with provided id.

@fkgozali
Copy link
Contributor

fkgozali commented May 6, 2019

I still need to run the simulated perf test to verify there's no regression.

seems like we can land this for now, just need to address the comments here before we land

@dulmandakh dulmandakh requested a review from fkgozali May 7, 2019 08:16
Copy link
Contributor

@facebook-github-bot facebook-github-bot left a comment

Choose a reason for hiding this comment

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

@fkgozali has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator.

@react-native-bot
Copy link
Collaborator

This pull request was successfully merged by @dulmandakh in fd6386a.

When will my fix make it into a release? | Upcoming Releases

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Component: Text Contributor A React Native contributor. Impact: Regression Describes a behavior that used to work on a prior release, but stopped working recently. Merged This PR has been merged. Platform: Android Android applications.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants