0%

字体增强

android 中使用字体是非常不方便的,因为不支持 xml 定义,必须使用代码来动态修改字体,也无法用样式或主题来批量修改字体。

现在我们来探索下面两个字体使用增强库的原理。

android-typeface-helper

android-typeface-helper 是较为简单,使用方法如下

1.首先在 Application 中初始化,主要是创建和缓存字体

1
2
3
4
5
6
7
8
@Override public void onCreate() {
super.onCreate();
// Initialize typeface helper
TypefaceCollection typeface = new TypefaceCollection.Builder()
.set(Typeface.NORMAL, Typeface.createFromAsset(getAssets(), "fonts/ubuntu/Ubuntu-R.ttf"))
.create();
TypefaceHelper.init(typeface);
}

2.对 Activity , ViewGroup 或 View 应用字体。

1
2
3
4
5
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_layout);
TypefaceHelper.typeface(this);
}

也可以动态的改变字体。

1
public static void typeface(View view);

这个库是实际没有做太多事,仅仅对常规字体使用方法的包装而已。typeface 方法如下

1
2
3
4
5
6
7
8
9
private static void applyForView(View view, TypefaceCollection typefaceCollection) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
Typeface oldTypeface = textView.getTypeface();
final int style = oldTypeface == null ? Typeface.NORMAL : oldTypeface.getStyle();
textView.setTypeface(typefaceCollection.getTypeface(style));
textView.setPaintFlags(textView.getPaintFlags() | Paint.SUBPIXEL_TEXT_FLAG);
}
}

可见字体的更改只应用在 TextView 控件上,对于 ViewGroup 要执行递归更改,对于 Acitivity 实际是对 content 布局执行递归更改。

值得一提的是你还可以创造某种字体的 SpannableString

1
public static SpannableString typeface(CharSequence sequence, TypefaceCollection typefaceCollection);

这是通过定义字体 span 获得的效果,不过首先通过集合提供字体对象

1
2
3
4
5
6
7
8
private static class TypefaceSpan extends MetricAffectingSpan {
Typeface typeface;

@Override public void updateDrawState(TextPaint tp) {
tp.setTypeface(typeface);
tp.setFlags(tp.getFlags() | Paint.SUBPIXEL_TEXT_FLAG);
}
}

Calligraphy

使用 Calligraphy 这个库,你可以直接在 XML 文件中定义字体

1
2
3
4
<TextView fontPath="fonts/MyFont.ttf"/>
<style name="TextAppearance.FontPath" parent="android:TextAppearance">
<item name="fontPath">fonts/RobotoCondensed-Regular.ttf</item>
</style>

自定义控件时添加一个属性并不稀奇,稀奇的是给系统控件 TextView 添加了一个属性 fontPath,而且生效了。

要做到这点,首先要在 Application 中进行设置。

1
2
3
4
5
6
7
8
@Override
public void onCreate() {
super.onCreate();
CalligraphyConfig.initDefault(new CalligraphyConfig.Builder()
.setDefaultFontPath("fonts/Roboto-RobotoRegular.ttf")
.setFontAttrId(R.attr.fontPath)
.build()
);

此外还需要在 Activity 中替换 Context

1
2
3
4
@Override
protected void attachBaseContext(Context newBase) {
super.attachBaseContext(CalligraphyContextWrapper.wrap(newBase));
}

当然该库也提供了 CalligraphyTypefaceSpan 供使用,不再叙述。

LayoutInflater的原理解析

android 是怎样解析属性的呢?核心是更改 LayoutInflater,该类的加载过程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public View inflate(@LayoutRes int resource, ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
final XmlResourceParser parser = res.getLayout(resource);
AttributeSet attrs = Xml.asAttributeSet(parser);

View result = root;
View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
temp.setLayoutParams(params);
}

if (root != null && attachToRoot) {
root.addView(temp, params);
}
if (root == null || !attachToRoot) {
result = temp;
}
return result;
}

这个过程实际是从布局文件中解析出 View 对象,再讲其添加到容器上去的过程。过程中有以下几点值得注意

1.attachToRoot 参数决定了是要要将View添加到容器上,以及返回值是容器自身还是View对象。

2.根据XML解析器生成View对象即 createViewFromTag 方法是一个移花接玉的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs){
TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
int themeResId = ta.getResourceId(0, 0);
context = new ContextThemeWrapper(context, themeResId);

View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}

if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs); //使用反射创建
} else {
view = createView(name, null, attrs); //使用反射创建
}
}
return view;
}

这里创建View的处理顺序是 Factory2,Factory1,PrivateFactory,最后才是使用反射来创建。

我们知道通过 Factory2 可以改变View的创建过程,实际上 Activity 就是这样做的,在执行 super.onCreate(savedInstanceState);这一句时就设置了 PrivateFactory。

简单实现

我们通过自定义一个 LayoutInflater 来实现简单的效果,首先定义一个属性

1
<attr name="customFont" format="string"/>

在xml中使用

1
<TextView customFont="fonts/Rotobo-Bold.ttf"/>

这时该属性已经能够被 LayoutInflater 解析到 AttributeSet 参数中去。我们可以使用 Factory2 接口来观察这一现象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Factory2 只能设置一次,所以先用反射修改 mFactorySet 的值,以解除此限制
LayoutInflater inflater = LayoutInflater.from(this);
Class cls = LayoutInflater.class;
Field bool = cls.getDeclaredField("mFactorySet");
bool.setAccessible(true);
bool.setBoolean(inflater, false);
inflater.setFactory2(new LayoutInflater.Factory2() {

@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if(name.equals("TextView")) {
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
Log.i(TAG, attrs.getAttributeName(i)); //customFont
Log.i(TAG, attrs.getAttributeValue(i));//fonts/Rotobo-Bold.ttf
}
return null;
}
});

从这里就可以拿到资源值(即字体文件路径)了,利用这一属性就可以设置字体了,简易实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if(name.equals("TextView")) {
TextView textview = new TextView(MainActivity.this);
textview.setLayoutParams(new ViewGroup.LayoutParams(400, 100));
textview.setText("中华大道 English hello 123456");
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
if(attrs.getAttributeName(i).equals("customfont")){
String path = attrs.getAttributeValue(i);
Typeface typeface = Typeface.createFromAsset(getAssets(), path);
textview.setTypeface(typeface);
}
}
return textview;
}
return null;
}

这个简单的实现已经能通过自定义属性来修改字体文件了,但离具体应用还差的比较远,比如它无法使用 style 和主题,也会屏蔽掉其它View的生成,此外还默认设置成默认应用在 Activity 上。

在Activity中应用字体属性

在Activity中应用字体属性就是要修改其中的LayoutInflater类,通过调查我们可以发现该类已经实现了 Factory2 接口,只要通过代理的方式来改造该接口的实现使其能够处理字体属性就行了。

实际过程分为两步

1.替换 Factory2 接口

1
2
3
4
5
6
7
8
9
10
public LayoutInflater getLayoutInflater() {
LayoutInflater inflater = super.getLayoutInflater();
final LayoutInflater.Factory2 factory2 = inflater.getFactory2();
Class cls = LayoutInflater.class;
Field bool = cls.getDeclaredField("mFactorySet");
bool.setAccessible(true);
bool.setBoolean(inflater, false);
inflater.setFactory2(new WrapFacory2(factory2));
return inflater;
}

注意一定要做如下设置,在活动创建时就替换 LayoutInflater 来

1
2
3
4
5
6
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLayoutInflater(); //改造已经存在的 LayoutInflater 对象
setContentView(R.layout.activity_main);
}

LayoutInflater 对象在 Window 中,必须要调用 getLayoutInflater 来改造它,才能在活动中生效。

2.利用代理扩展Factory2的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private class WrapFactory2 implements LayoutInflater.Factory2 {
LayoutInflater.Factory2 factory2;
public WrapFactory2(LayoutInflater.Factory2 factory2) {
this.factory2 = factory2;
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = factory2.onCreateView(parent, name, context, attrs);
if(view instanceof TextView){ //支持所有 TextView 的子类,如Button
int count = attrs.getAttributeCount();
for (int i = 0; i < count; i++) {
if(attrs.getAttributeName(i).equals("customfont")){
String path = attrs.getAttributeValue(i);
Typeface typeface = Typeface.createFromAsset(getAssets(), path);
((TextView)view).setTypeface(typeface);
}
}
}
return view;
}
}

这样字体属性在这个 Activity 中就可以生效了,而且完全不干扰其它控件和其它属性。

在样式或主题中使用字体属性

首先定义一个样式

1
2
3
<style name="Text" >
<item name="customFont">fonts/gtw.ttf</item>
</style>

此时应用样式的主要问题就是如何使用 AttributeSet 解析出其中的字体属性,方法如下

1
2
3
4
5
6
7
8
if(attrs.getAttributeName(i).equals("style")){
int styleId = attrs.getStyleAttribute();
TypedArray ta = obtainStyledAttributes(styleId, new int[]{R.attr.customFont});
String path = ta.getString(1);
ta.recycle();
Typeface typeface = Typeface.createFromAsset(getAssets(), path);
((TextView)view).setTypeface(typeface);
}

如果在主题中定义了字体属性,因为 AttributeSet 中不会有未使用的属性出现,自然就解析不出字体属性了。

1
2
3
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="customfont">fonts/gtw.ttf</item>
</style>

这时应该用主题对象(Theme)来解析出字体属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = factory2.onCreateView(parent, name, context, attrs);
if(view instanceof TextView && attrs!=null){
TypedArray ta = getTheme().obtainStyledAttributes(new int[]{
android.R.attr.colorAccent, //这里必须要以系统属性开头,否则自定义属性将解析错误
R.attr.customFont, //原因有待探索
});
String path = ta.getString(1);
ta.recycle();
Typeface typeface = Typeface.createFromAsset(getAssets(), path);
((TextView)view).setTypeface(typeface);
}
return view;
}

最后在实现时应该处理获取属性的优先级,一般而言,直接定义的优先级最高,style其次,主题再次之。