前言
在出现这个框架之前,我们要将应用字体替换为自定义字体基本只有三种方式:
1. 读取放在Assets目录下的字ttf文件,再通过 setTypeFace 方法设置,更通常的情况是通过自定义 View 的方式来实现字体切换,这样导致 app 中所有切换字体的地方都需要使用自定义 View,当你需要在Button、EditText、CheckBox 和 RadioButton 等继承自 TextView 的子类中实现自定义字体时又要创建对应的自定义View,这无疑是一种强耦合的写法,只能适合一些小型项目;
2. 对当前页面进行遍历,遇到继承自 TextView 的 View 就动态设置 typeface,优点是可以一次性替换大量控件的字体,避免写多个自定义控件的麻烦,缺点也很明显:
- 循环遍历消耗性能,对大量 view 的界面不太适合,容易造成页面卡顿现象;
- 对采用 cavas 的自定义画布的方式需要单独处理里面的文字显示;
3. 自定义Application, 在初始化阶段将系统的字体通过反射的方式将我们设置的系统字体替换为我们的自定义字体,优点是避免了一一写自定义View的麻烦,对应用性能造成的影响也较小,缺点是干涉系统字体在某些情况下会出现意想不到的问题。
Calligraphy
这个库的出现就是以更优雅的方式来解决替换字体时的耦合和性能问题的,项目地址点这里。
使用
一. 添加依赖
dependencies {
compile 'uk.co.chrisjenx:calligraphy:2.3.0'
}
二. 添加自定义字体文件到指定目录
将自定义字体放置在assets/目录下,以后使用过程中都将以此路径作为相对路径。当然你也可以在此路径下创建子目录,例如"fonts/"作为存放字体文件的目录,在布局文件中可以直接使用
<TextView fontPath="fonts/MyFont.ttf"/>
三. 全局设置自定义字体
1. 初始化字体配置
在Application的 onCreate 方法中初始化字体配置,如果不设置的话就不会生效
@Override
public void onCreate() {
super.onCreate();
CalligraphyConfig.initDefault(new CalligraphyConfig.Builder()
.setDefaultFontPath("fonts/Roboto-RobotoRegular.ttf")
.setFontAttrId(R.attr.fontPath)
.build()
);
//....
}
2. 注入自定义ContextImpl
attachBaseContext()方法原本是由系统来调用的,我们将自定义的ContextImpl对象作为参数传递到attachBaseContext()方法当中,从而赋值给mBase对象
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase));
}
四. 独立设置单个View的自定义字体
<TextView
android:text="@string/hello_world"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
fontPath="fonts/Roboto-Bold.ttf"/>
注意:常见的IDE(例如Android Studio, IntelliJ)可能会将此标记为错误。你需要在这些View或者ViewGroup中添加 tools:ignore="MissingPrefix" 这一工具域去避免这一问题。你需要添加这一工具域名去启用"ignore"属性。
源码分析
一. 概述
Calligraphy功能十分强大,从上面的说明中我们可以发现不仅支持简单的TextView,还支持继承于TextView的一些View,比如Button,EditText,CheckBox之类,还支持有setTypeFace()的自定义view。而且除了从View层面支持外,还包括从style,xml来进行个性化设置字体。
Calligraphy库中包含了10个类:
1.接口
CalligraphyActivityFactory:提供一个创建view的方法
HasTypeface:给一个标记告诉里面有需要设置字体的view
2.工具类
ReflectionUtils:用来获取方法字段,执行方法的Util类
TypefaceUtils:加载asset文件夹字体的Util类
CalligraphyUtils:给view设置字体的Util类
3.其他
CalligraphyConfig:全局配置类
CalligraphyLayoutInflater:继承系统自己实现的LayoutInflater,用来创建view
CalligraphyFactory:实现设置字体的地方
CalligraphyTypefaceSpan:Util中需要调用设置字体的类
CalligraphyContextWrapper:hook系统service的类
二. 原理
首先在Application中我们初始化了 CalligraphyConfig
,运用建造者模式来配置属性,其中类里面有一个静态块,初始了一些Map,里面存放的都是继承于TextView的一些View的 style 属性。
static {
{
DEFAULT_STYLES.put(TextView.class, android.R.attr.textViewStyle);
DEFAULT_STYLES.put(Button.class, android.R.attr.buttonStyle);
DEFAULT_STYLES.put(EditText.class, android.R.attr.editTextStyle);
DEFAULT_STYLES.put(AutoCompleteTextView.class, android.R.attr.autoCompleteTextViewStyle);
DEFAULT_STYLES.put(MultiAutoCompleteTextView.class, android.R.attr.autoCompleteTextViewStyle);
DEFAULT_STYLES.put(CheckBox.class, android.R.attr.checkboxStyle);
DEFAULT_STYLES.put(RadioButton.class, android.R.attr.radioButtonStyle);
DEFAULT_STYLES.put(ToggleButton.class, android.R.attr.buttonStyleToggle);
if (CalligraphyUtils.canAddV7AppCompatViews()) {
addAppCompatViews();
}
}
}
在最后使用了CalligraphyUtils中的canAddV7AppCompatViews方法判断是否能成功初始化AppCompatTextView类
/**
* See if the user has added appcompat-v7 with AppCompatViews
*
* @return true if AppcompatTextView is on the classpath
*/
static boolean canAddV7AppCompatViews() {
if (sAppCompatViewCheck == null) {
try {
Class.forName("android.support.v7.widget.AppCompatTextView");
sAppCompatViewCheck = Boolean.TRUE;
} catch (ClassNotFoundException e) {
sAppCompatViewCheck = Boolean.FALSE;
}
}
return sAppCompatViewCheck;
}
如果能则将各个继承于AppCompatTextView的 View 的 style 属性加入到DEFAULT_STYLES中
/**
* AppCompat will inflate special versions of views for Material tinting etc,
* this adds those classes to the style lookup map
*/
private static void addAppCompatViews() {
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatTextView.class, android.R.attr.textViewStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatButton.class, android.R.attr.buttonStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatEditText.class, android.R.attr.editTextStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatAutoCompleteTextView.class, android.R.attr.autoCompleteTextViewStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatMultiAutoCompleteTextView.class, android.R.attr.autoCompleteTextViewStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatCheckBox.class, android.R.attr.checkboxStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatRadioButton.class, android.R.attr.radioButtonStyle);
DEFAULT_STYLES.put(android.support.v7.widget.AppCompatCheckedTextView.class, android.R.attr.checkedTextViewStyle);
}
CalligraphyConfig中配置了字体相关的主要属性
/**
* Is a default font set?
*/
private final boolean mIsFontSet;
/**
* The default Font Path if nothing else is setup.
*/
private final String mFontPath;
/**
* Default Font Path Attr Id to lookup
*/
private final int mAttrId;
/**
* Use Reflection to inject the private factory.
*/
private final boolean mReflection;
/**
* Use Reflection to intercept CustomView inflation with the correct Context.
*/
private final boolean mCustomViewCreation;
/**
* Use Reflection to try to set typeface for custom views if they has setTypeface method
*/
private final boolean mCustomViewTypefaceSupport;
/**
* Class Styles. Build from DEFAULT_STYLES and the builder.
*/
private final Map<Class<? extends TextView>, Integer> mClassStyleAttributeMap;
/**
* Collection of custom non-{@code TextView}'s registered for applying typeface during inflation
* @see uk.co.chrisjenx.calligraphy.CalligraphyConfig.Builder#addCustomViewWithSetTypeface(Class)
*/
private final Set<Class<?>> hasTypefaceViews;
protected CalligraphyConfig(Builder builder) {
mIsFontSet = builder.isFontSet;
mFontPath = builder.fontAssetPath;
mAttrId = builder.attrId;
mReflection = builder.reflection;
mCustomViewCreation = builder.customViewCreation;
mCustomViewTypefaceSupport = builder.customViewTypefaceSupport;
final Map<Class<? extends TextView>, Integer> tempMap = new HashMap<>(DEFAULT_STYLES);
tempMap.putAll(builder.mStyleClassMap);
mClassStyleAttributeMap = Collections.unmodifiableMap(tempMap);
hasTypefaceViews = Collections.unmodifiableSet(builder.mHasTypefaceClasses);
}
除了Application需要配置外,还需要在Activity的 attachBaseContext 方法注入用 CalligraphyContextWrapper
包装后的的ContextImpl,关于attachBaseContext的作用请查看《深入理解Android中的context》一文
private CalligraphyLayoutInflater mInflater;
...
/**
* Uses the default configuration from {@link uk.co.chrisjenx.calligraphy.CalligraphyConfig}
*
* Remember if you are defining default in the
* {@link uk.co.chrisjenx.calligraphy.CalligraphyConfig} make sure this is initialised before
* the activity is created.
*
* @param base ContextBase to Wrap.
* @return ContextWrapper to pass back to the activity.
*/
public static ContextWrapper wrap(Context base) {
return new CalligraphyContextWrapper(base);
}
...
@Override
public Object getSystemService(String name) {
if (LAYOUT_INFLATER_SERVICE.equals(name)) {
if (mInflater == null) {
mInflater = new CalligraphyLayoutInflater(LayoutInflater.from(getBaseContext()), this, mAttributeId, false);
}
return mInflater;
}
return super.getSystemService(name);
}
可以看到 CalligraphyContextWrapper
里包含了一个CalligraphyLayoutInflater的属性,当Activity进行布局初始化时hook了LAYOUT_INFLATER_SERVICE服务,并将CalligraphyLayoutInflater属性进行初始化。
继续跟进CalligraphyLayoutInflater类,可以看到他的构造方法如下:
protected CalligraphyLayoutInflater(LayoutInflater original, Context newContext, int attributeId, final boolean cloned) {
super(original, newContext);
mAttributeId = attributeId;
mCalligraphyFactory = new CalligraphyFactory(attributeId);
setUpLayoutFactories(cloned);
}
其中mAttributeId是在Application中初始化CalligraphyConfig时设置的,用来作为配置字体时的前缀
/**
* This defaults to R.attr.fontPath. So only override if you want to use your own attrId.
*
* @param fontAssetAttrId the custom attribute to look for fonts in assets.
* @return this builder.
*/
public Builder setFontAttrId(int fontAssetAttrId) {
this.attrId = fontAssetAttrId;
return this;
}
最后调用了setUpLayoutFactories(cloned)方法,并传入 cloned
参数
/**
* We don't want to unnecessary create/set our factories if there are none there. We try to be
* as lazy as possible.
*/
private void setUpLayoutFactories(boolean cloned) {
if (cloned) return;
// If we are HC+ we get and set Factory2 otherwise we just wrap Factory1
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
if (getFactory2() != null && !(getFactory2() instanceof WrapperFactory2)) {
// Sets both Factory/Factory2
setFactory2(getFactory2());
}
}
// We can do this as setFactory2 is used for both methods.
if (getFactory() != null && !(getFactory() instanceof WrapperFactory)) {
setFactory(getFactory());
}
}
根据版本是否大于11分为了两种Factory,其中Factory和Factory2是LayoutInflater内部的两个接口
public interface Factory2 extends LayoutInflater.Factory {
View onCreateView(View var1, String var2, Context var3, AttributeSet var4);
}
public interface Factory {
View onCreateView(String var1, Context var2, AttributeSet var3);
}
首次调用会执行 setFactory2(getFactory2()) 方法,我们可以看到 CallinggraphyContextWrapper 中重写了 setFactory2方法
@Override
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public void setFactory2(Factory2 factory2) {
// Only set our factory and wrap calls to the Factory2 trying to be set!
if (!(factory2 instanceof WrapperFactory2)) {
// LayoutInflaterCompat.setFactory(this, new WrapperFactory2(factory2, mCalligraphyFactory));
super.setFactory2(new WrapperFactory2(factory2, mCalligraphyFactory));
} else {
super.setFactory2(factory2);
}
}
我们再跟进WrapperFactory2, 可以看到它是 Factory2 的一个包装类
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private static class WrapperFactory2 implements Factory2 {
protected final Factory2 mFactory2;
protected final CalligraphyFactory mCalligraphyFactory;
public WrapperFactory2(Factory2 factory2, CalligraphyFactory calligraphyFactory) {
mFactory2 = factory2;
mCalligraphyFactory = calligraphyFactory;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return mCalligraphyFactory.onViewCreated(
mFactory2.onCreateView(name, context, attrs),
context, attrs);
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
return mCalligraphyFactory.onViewCreated(
mFactory2.onCreateView(parent, name, context, attrs),
context, attrs);
}
}
构造函数包含两个参数,其一是实现factory2接口的一个实例,其二是我们之前初始化的 CalligraphyFactory 实例。
在实现Factory2 接口的两个方法中,可以看到我们最终调用的是 CalligraphyFactory
的 onViewCreated
方法,我们继续跟进CalligraphyFactory中的 onCreateView 方法
/**
* Handle the created view
*
* @param view nullable.
* @param context shouldn't be null.
* @param attrs shouldn't be null.
* @return null if null is passed in.
*/
public View onViewCreated(View view, Context context, AttributeSet attrs) {
if (view != null && view.getTag(R.id.calligraphy_tag_id) != Boolean.TRUE) {
onViewCreatedInternal(view, context, attrs);
view.setTag(R.id.calligraphy_tag_id, Boolean.TRUE);
}
return view;
}
如果该 View没有被设置过字体,那么就会调用 onViewCreatedInternal
的方法,并被设置tag
void onViewCreatedInternal(View view, final Context context, AttributeSet attrs) {
if (view instanceof TextView) {
// Fast path the setting of TextView's font, means if we do some delayed setting of font,
// which has already been set by use we skip this TextView (mainly for inflating custom,
// TextView's inside the Toolbar/ActionBar).
if (TypefaceUtils.isLoaded(((TextView) view).getTypeface())) {
return;
}
// Try to get typeface attribute value
// Since we're not using namespace it's a little bit tricky
// Check xml attrs, style attrs and text appearance for font path
String textViewFont = resolveFontPath(context, attrs);
// Try theme attributes
if (TextUtils.isEmpty(textViewFont)) {
final int[] styleForTextView = getStyleForTextView((TextView) view);
if (styleForTextView[1] != -1)
textViewFont = CalligraphyUtils.pullFontPathFromTheme(context, styleForTextView[0], styleForTextView[1], mAttributeId);
else
textViewFont = CalligraphyUtils.pullFontPathFromTheme(context, styleForTextView[0], mAttributeId);
}
// Still need to defer the Native action bar, appcompat-v7:21+ uses the Toolbar underneath. But won't match these anyway.
final boolean deferred = matchesResourceIdName(view, ACTION_BAR_TITLE) || matchesResourceIdName(view, ACTION_BAR_SUBTITLE);
CalligraphyUtils.applyFontToTextView(context, (TextView) view, CalligraphyConfig.get(), textViewFont, deferred);
}
// AppCompat API21+ The ActionBar doesn't inflate default Title/SubTitle, we need to scan the
// Toolbar(Which underlies the ActionBar) for its children.
if (CalligraphyUtils.canCheckForV7Toolbar() && view instanceof android.support.v7.widget.Toolbar) {
applyFontToToolbar((Toolbar) view);
}
// Try to set typeface for custom views using interface method or via reflection if available
if (view instanceof HasTypeface) {
Typeface typeface = getDefaultTypeface(context, resolveFontPath(context, attrs));
if (typeface != null) {
((HasTypeface) view).setTypeface(typeface);
}
} else if (CalligraphyConfig.get().isCustomViewTypefaceSupport() && CalligraphyConfig.get().isCustomViewHasTypeface(view)) {
final Method setTypeface = ReflectionUtils.getMethod(view.getClass(), "setTypeface");
String fontPath = resolveFontPath(context, attrs);
Typeface typeface = getDefaultTypeface(context, fontPath);
if (setTypeface != null && typeface != null) {
ReflectionUtils.invokeMethod(view, setTypeface, typeface);
}
}
}
大致流程:首先判断该控件是否是 TextView 的子类,然后如果已经设置过字体就直接跳过,往下走就是 resolveFontPath
方法,依次从xml,style 和 TextAppearance 中获取字体文件的路径,如果没找到则设置为默认的自定义属性。最后调用 CalligraphyUtils 中的 applyFontToTextView 方法使字体生效。除了继承于TextView 的子View 之外,还对ToolBar和 ActionBar做了适配。
/**
* Applies a Typeface to a TextView, if deferred,its recommend you don't call this multiple
* times, as this adds a TextWatcher.
*
* Deferring should really only be used on tricky views which get Typeface set by the system at
* weird times.
*
* @param textView Not null, TextView or child of.
* @param typeface Not null, Typeface to apply to the TextView.
* @param deferred If true we use Typefaces and TextChange listener to make sure font is always
* applied, but this sometimes conflicts with other
* {@link android.text.Spannable}'s.
* @return true if applied otherwise false.
* @see #applyFontToTextView(android.widget.TextView, android.graphics.Typeface)
*/
public static boolean applyFontToTextView(final TextView textView, final Typeface typeface, boolean deferred) {
if (textView == null || typeface == null) return false;
textView.setPaintFlags(textView.getPaintFlags() | Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG);
textView.setTypeface(typeface);
if (deferred) {
textView.setText(applyTypefaceSpan(textView.getText(), typeface), TextView.BufferType.SPANNABLE);
textView.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
applyTypefaceSpan(s, typeface);
}
});
}
return true;
}
可以看到在该方法中设置了字体,如果碰到Spannable,还需要延迟处理。
总结
Calligraphy核心实际上就是 自定义LayoutInflater以及其中的Factory来hook住系统构建View的过程,并且替换为我们自己的处理方式,由此引申开来,无论是切换字体还是皮肤都是一样的道理。