多语言适配
bidi 算法(双向字符)、BidiFormatter 详解及 mashi 适配案例
Bidi 基础
双向字符类型
书写方向是和文字相关,阿拉伯文字从右到左,拉丁文字从左到右。当人们在纸上书写时当然会记得这些规则,那计算机是如何知道的呢?实际上,Unicode 定义了它其中每个字符的方向属性,计算机就是根据这个方向属性来判断该文字的方向。
Unicode 方向属性包含三种类型:强字符、弱字符和中性字符
强字符
大部分的字符都属于强字符。它们的方向性是确定的,从左到右或者从右到左,和其上下文的 bidi 属性无关。并且,强字符在 bidi 算法中可能会影响其前后字符的方向性。
- 左到右(LTR)
拉丁文字 (英文字母)、汉字 - 右到左(RTL)
RTL 语言有以下 6 种:
| 语种 | 语言代码 | 国家 | 示例 |
|---|---|---|---|
| 阿拉伯语 | ar | Arbic | العربية |
| 波斯语 | fa | Persian | فارسی |
| 希伯来语 | iw | Hebrew | עברית |
| 乌尔都语(印度、巴基斯坦) | ur | Urdu | اردو |
| 维吾尔语 | - | Uyghur | - |
弱字符
弱字符的特性,它们的方向是确定的,但对其前后字符的方向性并不会产生影响。数字和数字相关的一些符号就属于弱字符。
- 西阿拉伯数字 (LTR):(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
- 东阿拉伯数字:(٠ - ١ - ٢ - ٣ - ٤ - ٥ - ٦ - ٧ - ٨ - ٩)
- 波斯数字:(۰ - ۱ - ۲ - ۳ - ۴ - ۵ - ۶ - ۷ - ۸ - ۹)
- 其他有一些语言也有自己的数字.
中性字符
中性字符的方向性是不确定的,由上下文的 bidi 属性来决定其方向,如 android:textDirection
段落分隔符、制表符和大多数其他空格字符- 比如大部分的标点符号,
%,@,.,+,-,[],()、空格等。 $是强方向字符,左到右
案例显示:
<string name="plus_user_ar">+ يوم</string>
在 as 中显示:
手机显示效果:
android:textDirection="ltr"

android:layoutDirection="locale"

特殊字符的类型
Unicode 控制字符方向 (Unicode bidi support)
大部分情况,Unicode 双向算法能根据字符属性和全局方向等信息运算并正确地显示双向文字,这是该算法的隐性模式。在这种模式下,双向文字的显示方式基本上由算法完成,不需要人为的干预。但是,隐性模式的算法在处理复杂情况的双向文字时会显得不足,这时就可以使用显性模式来进行补充。在显性模式的算法中,除了隐性算法的运算外,可以在双向文字中加入关于方向的 Unicode 控制字符来控制文字的显示。这些被加入文字中的 Unicode 控制字符在显示界面上是不可见的,也不占用任何显示空间。它们只是在默默地影响着双向文字的显示。
隐性双向控制字符
U+200E: LEFT-TO-RIGHT MARK (LRM)
U+200F: RIGHT-TO-LEFT MARK (RLM)
您可以将这类的控制字符看成是不会显示出来的强字符,LRM 为从左到右的强字符,而 RLM 为从右到左的强字符。
代码用: \u200E
显性双向控制字符
这类控制字符需要成对使用,列表中的前四个为开始字符,而最后一个为结束字符。
U+202A: LEFT-TO-RIGHT EMBEDDING (LRE)
U+202B: RIGHT-TO-LEFT EMBEDDING (RLE)
U+202D: LEFT-TO-RIGHT OVERRIDE (LRO)
U+202E: RIGHT-TO-LEFT OVERRIDE (RLO)
U+202C: POP DIRECTIONAL FORMATTING (PDF)
- LRE
当双向算法遇到 LRE 时,接下来文字片段内的方向开始变为从左到右 - RLE
当双向算法遇到 RLE 时,接下来文字片段内的方向开始变为从右到左 - LRO
当遇到 LRO 时,双向算法会将后面所有文字的双向属性视为从左到右强字符。 - RLO
当遇到 RLO 时,双向算法会将后面所有文字的双向属性视为从右到左强字符。 - PDF
如果一旦遇到 PDF 字符,双向属性的状态就会恢复到最后一个 LRE、RLE、LRO 或 RLO 之前的状态。
BidiFormatter 用法
TextDirectionHeuristicCompat 暂且称呼为方向评估器
用于推断一段文本的方向,内置的方向评估器有:
TextDirectionHeuristicsCompat.LTR
方向总是 left to right
TextDirectionHeuristicsCompat.RTL
方向总是 right to left
TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR 默认
Unicode Bidirectional Algorithm 默认,TextView 的 android:textDirection/setTextDirection 默认;取第一个强字符 (包括 bidi 控制字符) 的方向作为文本方向,如果没有强字符,该段落的文本方向是 LTR
TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL
取第一个强字符 (包括 bidi 控制字符) 的方向作为文本方向,如果没有强字符,该段落的文本方向是 RTL
TextDirectionHeuristicsCompat.ANYRTL_LTR
存在任何 right to left 的 non-format 字符确定方向为 right to left;否则为 left to right
TextDirectionHeuristicsCompat.LOCALE
强制方向为 locale;Falls back to left to right.
BidiFormatter 作用
1. Directionality estimation
BidiFormatter 默认方向评估器是的 TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR
- public boolean isRtl(String str)
给定的文本方向是否是 RTL;用给定的mDefaultTextDirectionHeuristicCompat来推断 str 的方向,默认方向评估器为FIRSTSTRONG_LTR
2. Bidi Wrapping
- public CharSequence unicodeWrap(CharSequence str, TextDirectionHeuristicCompat heuristic, boolean isolate)
- 参数 1 str 要包裹的文本
- 参数 2 heuristic 文本方向评估器,用来评估整段 str 的方向
- 参数 3 isolate 是否隔离,防止其影响前后字符方向
BidiFormatter 实用案例
案例 1:充值优惠文案
BidiFormatter 详解
BidiFormatter 实例化
从 BidiFormatter 实例化入口开始看:
public static BidiFormatter getInstance() {
return new Builder().build();
}
BidiFormatter 实例化用了 Builder 模式,再看 Builder() 默认值
当前Locale方向是否是RTL
static boolean isRtlLocale(Locale locale) {
return (TextUtilsCompat.getLayoutDirectionFromLocale(locale) == ViewCompat.LAYOUT_DIRECTION_RTL);
}
public static final class Builder {
private boolean mIsRtlContext;
private int mFlags;
private TextDirectionHeuristicCompat mTextDirectionHeuristicCompat;
public Builder() {
initialize(isRtlLocale(Locale.getDefault()));
}
private void initialize(boolean isRtlContext) {
mIsRtlContext = isRtlContext;
mTextDirectionHeuristicCompat = DEFAULT_TEXT_DIRECTION_HEURISTIC;
mFlags = DEFAULT_FLAGS;
}
public BidiFormatter build() {
if (mFlags == DEFAULT_FLAGS &&
mTextDirectionHeuristicCompat == DEFAULT_TEXT_DIRECTION_HEURISTIC) {
return getDefaultInstanceFromContext(mIsRtlContext);
}
return new BidiFormatter(mIsRtlContext, mFlags, mTextDirectionHeuristicCompat);
}
}
小结:
- BidiFormatter 默认的全局方向 mIsRtlContext 是根据当前 Locale 才决定的,阿语环境是 RTL,中/英语环境是 LTR
- mTextDirectionHeuristicCompat 方向推理器默认为
DEFAULT_TEXT_DIRECTION_HEURISTIC
DEFAULT_TEXT_DIRECTION_HEURISTIC 是什么?
// 默认方向推理器
static final TextDirectionHeuristicCompat DEFAULT_TEXT_DIRECTION_HEURISTIC = FIRSTSTRONG_LTR;
// 根据第一个强字符来确定方向,包括bidi控制字符;如果找不到默认为LTR方向;这个是双向字符算法默认行为
public static final androidx.core.text.TextDirectionHeuristicCompat FIRSTSTRONG_LTR =
new TextDirectionHeuristicInternal(FirstStrong.INSTANCE, false);
unicodeWrap
unicodeWrap 很多重载的方法,比如常用的
public String unicodeWrap(String str) {
return unicodeWrap(str, mDefaultTextDirectionHeuristicCompat, true /* isolate */);
}
最终都是走到这里:
public CharSequence unicodeWrap(CharSequence str, TextDirectionHeuristicCompat heuristic, boolean isolate) {
if (str == null) return null;
final boolean isRtl = heuristic.isRtl(str, 0, str.length()); // 当前文本是否RTL根据heuristic
SpannableStringBuilder result = new SpannableStringBuilder();
if (getStereoReset() && isolate) { // 需要隔离,
// 添加隔离符
result.append(markBefore(str,
isRtl ? TextDirectionHeuristicsCompat.RTL : TextDirectionHeuristicsCompat.LTR));
}
if (isRtl != mIsRtlContext) {
// 如果文本方向和上下文方向不一致,根据文本方向添加对应的RLE和LRE
result.append(isRtl ? RLE : LRE);
result.append(str);
result.append(PDF);
} else {
result.append(str);
}
if (isolate) {
result.append(markAfter(str,
isRtl ? TextDirectionHeuristicsCompat.RTL : TextDirectionHeuristicsCompat.LTR));
}
return result;
}
// 在文本前面添加;对于RTL文本添加LRM在LTR上下文;对于LTR文本添加RLM在RTL上下文;其他情况添加空串
private String markBefore(CharSequence str, TextDirectionHeuristicCompat heuristic) {
final boolean isRtl = heuristic.isRtl(str, 0, str.length());
// getEntryDir() is called only if needed (short-circuit).
if (!mIsRtlContext && (isRtl || getEntryDir(str) == DIR_RTL)) {
return LRM_STRING;
}
if (mIsRtlContext && (!isRtl || getEntryDir(str) == DIR_LTR)) {
return RLM_STRING;
}
return EMPTY_STRING;
}
// 具体逻辑同markBefore,只是在文本结尾处添加
private String markAfter(CharSequence str, TextDirectionHeuristicCompat heuristic) {
final boolean isRtl = heuristic.isRtl(str, 0, str.length());
// getExitDir() is called only if needed (short-circuit).
if (!mIsRtlContext && (isRtl || getExitDir(str) == DIR_RTL)) {
return LRM_STRING;
}
if (mIsRtlContext && (!isRtl || getExitDir(str) == DIR_LTR)) {
return RLM_STRING;
}
return EMPTY_STRING;
}
Ref
多语言适配问题
HTTP header 非 ASCII 码不能作为 header name/value,OKHttp 有检测
原因
java.lang.IllegalArgumentException: Unexpected char 0x660 at 15 in Tz value: Asia/Shanghai,+٠٨:٠٠

HTTP header 非 ASCII 码不能作为 header name/value,OKHttp 有检测
# Headers
static void checkName(String name) {
if (name == null) throw new NullPointerException("name == null");
if (name.isEmpty()) throw new IllegalArgumentException("name is empty");
for (int i = 0, length = name.length(); i < length; i++) {
char c = name.charAt(i);
if (c <= '\u0020' || c >= '\u007f') {
throw new IllegalArgumentException(Util.format(
"Unexpected char %#04x at %d in header name: %s", (int) c, i, name));
}
}
}
static void checkValue(String value, String name) {
if (value == null) throw new NullPointerException("value for name " + name + " == null");
for (int i = 0, length = value.length(); i < length; i++) {
char c = value.charAt(i);
if ((c <= '\u001f' && c != '\t') || c >= '\u007f') {
throw new IllegalArgumentException(Util.format(
"Unexpected char %#04x at %d in %s value: %s", (int) c, i, name, value));
}
}
}
解决 1:
用 Locale.ENGLISH 来格式化
@JvmStatic
fun getCurrentTimezone(): String {
val tz = TimeZone.getDefault()
val cal = GregorianCalendar.getInstance(tz)
val offsetInMillis = tz.getOffset(cal.timeInMillis)
var offset = String.format(Locale.ENGLISH, "%02d:%02d",
abs(offsetInMillis / 3600000),
abs(offsetInMillis / 60000 % 60))
offset = (if (offsetInMillis >= 0) "+" else "-") + offset
return "${TimeZone.getDefault().id},${offset}"
}
解决 2:
进行 URLEncode
阿语下语音聊天室通过 WebSocket 发送某些特殊字符崩溃
特殊字符:
_ヽ \\ Λ_Λ \( 'ㅅ' ) > ⌒ヽ / へ\ / / \\ レ ノ ヽ_つ / / / /| ( (ヽ _ヽ \\ Λ_Λ \( 'ㅅ' ) > ⌒ヽ / へ\ / / \\ レ ノ ヽ_つ / / / /| ( (ヽ ⊂_ヽ \\ Λ_Λ \( 'ㅅ' )
崩溃日志:
Fatal Exception: java.lang.IndexOutOfBoundsException: measureLimit (32) is out of start (36) and limit (32) bounds
at android.text.TextLine.handleRun + 1113(TextLine.java:1113)
at android.text.TextLine.drawRun + 509(TextLine.java:509)
at android.text.TextLine.draw + 280(TextLine.java:280)
at android.text.Layout.drawText + 581(Layout.java:581)
at android.text.Layout.draw + 333(Layout.java:333)
at android.widget.TextView.onDraw + 8108(TextView.java:8108)
at android.view.View.draw + 21870(View.java:21870)
at android.view.View.updateDisplayListIfDirty + 20743(View.java:20743)
at android.view.View.draw + 21596(View.java:21596)
at android.view.ViewGroup.drawChild + 4558(ViewGroup.java:4558)
at android.view.ViewGroup.dispatchDraw + 4333(ViewGroup.java:4333)
at android.view.View.draw + 21873(View.java:21873)
at android.view.View.updateDisplayListIfDirty + 20743(View.java:20743)
at android.view.View.draw + 21596(View.java:21596)
at android.view.ViewGroup.drawChild + 4558(ViewGroup.java:4558)
at android.view.ViewGroup.dispatchDraw + 4333(ViewGroup.java:4333)
at android.view.View.updateDisplayListIfDirty + 20729(View.java:20729)
at android.view.View.draw + 21596(View.java:21596)
at android.view.ViewGroup.drawChild + 4558(ViewGroup.java:4558)
at android.support.v7.widget.RecyclerView.drawChild + 4703(RecyclerView.java:4703)
at android.view.ViewGroup.dispatchDraw + 4333(ViewGroup.java:4333)
at android.view.View.draw + 21873(View.java:21873)
at android.support.v7.widget.RecyclerView.draw + 4107(RecyclerView.java:4107)
at android.view.View.updateDisplayListIfDirty + 20743(View.java:20743)
at android.view.View.draw + 21596(View.java:21596)
at android.view.ViewGroup.drawChild + 4558(ViewGroup.java:4558)
at android.view.ViewGroup.dispatchDraw + 4333(ViewGroup.java:4333)
at android.view.View.updateDisplayListIfDirty + 20729(View.java:20729)
at android.view.View.draw + 21596(View.java:21596)
at android.view.ViewGroup.drawChild + 4558(ViewGroup.java:4558)
at android.view.ViewGroup.dispatchDraw + 4333(ViewGroup.java:4333)
at android.view.View.updateDisplayListIfDirty + 20729(View.java:20729)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ViewGroup.recreateChildDisplayList + 4542(ViewGroup.java:4542)
at android.view.ViewGroup.dispatchGetDisplayList + 4514(ViewGroup.java:4514)
at android.view.View.updateDisplayListIfDirty + 20698(View.java:20698)
at android.view.ThreadedRenderer.updateViewTreeDisplayList + 725(ThreadedRenderer.java:725)
at android.view.ThreadedRenderer.updateRootDisplayList + 731(ThreadedRenderer.java:731)
at android.view.ThreadedRenderer.draw + 840(ThreadedRenderer.java:840)
at android.view.ViewRootImpl.draw + 3981(ViewRootImpl.java:3981)
at android.view.ViewRootImpl.performDraw + 3755(ViewRootImpl.java:3755)
at android.view.ViewRootImpl.performTraversals + 3064(ViewRootImpl.java:3064)
at android.view.ViewRootImpl.doTraversal + 1927(ViewRootImpl.java:1927)
at android.view.ViewRootImpl$TraversalRunnable.run + 8558(ViewRootImpl.java:8558)
at android.view.Choreographer$CallbackRecord.run + 949(Choreographer.java:949)
at android.view.Choreographer.doCallbacks + 761(Choreographer.java:761)
at android.view.Choreographer.doFrame + 696(Choreographer.java:696)
at android.view.Choreographer$FrameDisplayEventReceiver.run + 935(Choreographer.java:935)
at android.os.Handler.handleCallback + 873(Handler.java:873)
at android.os.Handler.dispatchMessage + 99(Handler.java:99)
at android.os.Looper.loop + 214(Looper.java:214)
at android.app.ActivityThread.main + 7094(ActivityThread.java:7094)
at java.lang.reflect.Method.invoke(Method.java)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run + 494(RuntimeInit.java:494)
at com.android.internal.os.ZygoteInit.main + 975(ZygoteInit.java:975)
https://console.firebase.google.com/project/qsbk-voicechat-001/crashlytics/app/android: club.jinmei.mgvoice/issues/619ae6add3d2aa8dadbc96edde8ef832?time=last-seven-days>
https://console.firebase.google.com/project/qsbk-voicechat-001/crashlytics/app/android: club.jinmei.mgvoice/issues/2a55b0caccda193220618099fcb1b55c?time=last-seven-days&sessionId=5DF01588005700012EF1313C42B30DC3_DNE_0_v2>
原因:
在阿语环境下,这些字符会引起崩溃;非阿语不会崩溃
聊天消息和昵称以及其他一些徽章图片等都是在同一个textview里面,通过spannable来进行排版的。阿拉伯字母的出现打乱了spannable顺序,引起了这次事故
解决 1:
TextView 和 EditText 需要加上,似乎没有用
android:textDirection="ltr"
android:textAlignment="viewStart"
解决 2:先 try catch,有问题换掉一些空格,再调用。
Mashi 这样处理的,具体看线上情况
public class BaseCoreTextView extends AppCompatTextView {
private boolean mAutoRetryWhenIndexOutOfBoundsException = true;
public BaseCoreTextView(Context context) {
super(context);
}
public BaseCoreTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BaseCoreTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* 字符串转换unicode
*/
public final static String string2Unicode(CharSequence string) {
StringBuffer unicode = new StringBuffer();
for (int i = 0; i < string.length(); i++) {
// 取出每一个字符
char c = string.charAt(i);
// 转换为unicode
unicode.append("\\u" + Integer.toHexString(c));
}
return unicode.toString();
}
/**
* 当onDraw 出现IndexOutOfBoundsException的时候,是否自动重试
*/
public boolean isAutoRetryWhenIndexOutOfBoundsException() {
return mAutoRetryWhenIndexOutOfBoundsException;
}
/**
* 当onDraw 出现IndexOutOfBoundsException的时候,是否自动重试
*/
public void setAutoRetryWhenIndexOutOfBoundsException(boolean autoRetry) {
this.mAutoRetryWhenIndexOutOfBoundsException = autoRetry;
}
@Override
protected void onDraw(Canvas canvas) {
try {
super.onDraw(canvas);
} catch (IndexOutOfBoundsException e) {
if (mAutoRetryWhenIndexOutOfBoundsException) {
CharSequence text = getText();
text = StringCodeUtils.replaceWiredSpace2NormalSpace(text);
setText(text);
}
boolean isRetry = (mAutoRetryWhenIndexOutOfBoundsException);
logIndexOutOfBoundsException(e, isRetry);
// 不抛出异常,避免崩溃
// throw e;
} catch (Throwable e) {
}
}
private void logIndexOutOfBoundsException(IndexOutOfBoundsException e, boolean isRetry) {
final int id = getId();
final CharSequence text = getText();
final Map<String, String> map = new HashMap<>();
map.put("id", String.valueOf(id));
map.put("text", string2Unicode(text));
map.put("exception", e.getMessage());
map.put("retry", (isRetry ? "1" : "0"));
Statistic.getInstance().onEvent(getContext(), "TextView", map);
}
}
public final class StringCodeUtils {
public static final CharSequence NORMAL_SPACE = " ";
/**
* 把一些恶心的space替换成正常的space
*/
public static CharSequence replaceWiredSpace2NormalSpace(CharSequence charSequence) {
if (!TextUtils.isEmpty(charSequence)) {
final int length = charSequence.length();
StringBuilder stringBuilder = new StringBuilder(length);
char ch;
for (int i = 0; i < length; i++) {
ch = charSequence.charAt(i);
if (Character.isSpaceChar(ch) || Character.isWhitespace(ch)) {
stringBuilder.append(NORMAL_SPACE);
} else {
stringBuilder.append(ch);
}
}
return stringBuilder.toString();
}
return charSequence;
}
}
Ref
- 阿拉伯字母与 LTR 书写语系混排奔溃问题
https://www.androidcycle.com/阿拉伯字母与ltr书写语系混排奔溃问题-ltr-rtl-with-spannable-in-the-same/
阿语英文混编
BidiFormatter
双方向字符摆放,插入控制字符,来保证段落字符的正确摆放
- 根据给定的
TextDirectionHeuristicCompat,推断出给定文本的方向,插入 bidi 控制字符 - 当前的文本的方向和全局方向相反的,会插入对应的 bidi 字符,让这段文本显示正确
en 环境的全局方向为 LTR;ar 环境全局方向为 RTL
隐性双向控制字符:
U+200E: LEFT-TO-RIGHT MARK (LRM) 从左到右的强字符
U+200F: RIGHT-TO-LEFT MARK (RLM) 从右到左的强字符
显性双向控制字符:
U+202A: LEFT-TO-RIGHT EMBEDDING (LRE) 接下来文字片段内的方向开始变为从左到右
U+202B: RIGHT-TO-LEFT EMBEDDING (RLE) 接下来文字片段内的方向开始变为从右到左
U+202D: LEFT-TO-RIGHT OVERRIDE (LRO) 后面所有文字的双向属性视为从左到右强字符
U+202E: RIGHT-TO-LEFT OVERRIDE (RLO) 后面所有文字的双向属性视为从右到左强字符
U+202C: POP DIRECTIONAL FORMATTING (PDF) 一旦遇到 PDF 字符,双向属性的状态就会恢复到最后一个 LRE、RLE、LRO 或 RLO 之前的状态。
1. 英文 + 阿语(首字符英文,LTR)
en 环境
- 默认行为,从左到右显示,英文在前,阿语在后
- BidiFormatter 默认行为,是根据首字母来确定该段文本方向,首字母英文,那该段文本的方向为从左到右,显示同
1.默认行为 - BidiFormatter ANYRTL_LTR 行为,有任何 RTL 字符就确定为 RLT 方向,所以该段文本从右到左显示
ar 环境

2. 阿语 + 英文(首字符英文,RTL)
en

ar

3. @+ 英文 + 阿语 (首字符弱字符)
en

ar

4. @+ 阿语 + 英文 (首字符弱字符)
en

ar

5. 数字 + 英文 + 阿语(首字符中性字符)
en

ar

6. 数字 + 阿语 + 英文(首字符中性字符)
en

ar 环境

7. 阿语 + 百分比带小数点(首字符,阿语)
en

ar

这里显示不正确,en 和 ar% 都没有跟在数字后面
双方向字符案例
购物车文案显示(- 开头显示错误)

RTL 下,- 号跑到了右边,期望在左边
<TextView
style="@style/LabelText"
android:layout_marginTop="10dp"
android:text="洗音购物车文案" />
<TextView
style="@style/LabelTextSmall"
android:text="默认" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="lrt" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textDirection="ltr"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="firstStrongLtr" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textDirection="firstStrongLtr"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="U+200E(左→右)" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textDirection="firstStrongLtr"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="U+200F(右→左)" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="BidiFormatter(TextDirectionHeuristicsCompat.LTR)" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/red_A100" />
<TextView
style="@style/LabelTextSmall"
android:text="BidiFormatter(TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR)" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_cart_text7"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/red_A100" />
tv_cart_text.text = "-\$7.47"
tv_cart_text2.text = "-\$7.47"
tv_cart_text3.text = "-\$7.47"
tv_cart_text4.text = "\u200E-\$7.47"
tv_cart_text5.text = "\u200F-\$7.47"
val bidi = BidiFormatter.Builder()
.setTextDirectionHeuristic(TextDirectionHeuristicsCompat.LTR)
.build()
tv_cart_text6.text = bidi.unicodeWrap("-\$7.47")
val bidi2 = BidiFormatter.Builder()
.setTextDirectionHeuristic(TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR)
.build()
tv_cart_text7.text = bidi2.unicodeWrap("-\$7.47")

7 天 + 显示
!
第二个和第四个效果显示正确
<string name="one_day">%dيوم</string>
<string name="more_days">%dيوم</string>
<string name="last_days">%dيوم+</string>
在前面加上 \u202B 特殊字符
@用户名和内容,中英/阿语混编问题同 WhatsApp 一样处理
英语指非阿语,含英语、中文等所有从左往右的语言
系统语言不干涉用户用户文本框内容,只影响页面布局左右排版
测试
val s1 = "@%s 你好啊."
val s2 = "@%s فارسی."
// 3种name
val name_en = "hacket" // 英文
val name_ar = "ماجكوو" // 阿语
val name_ar_en = "♠️♨️༆㋡⃢ماجكوو🤣༗" // 阿语符号混合
TextView 默认

android:textDirection="firstStrongRtl/firstStrongLtr/firstStrong"
firstStrongRtl/firstStrongLtr/firstStrong 都是由第一个强字符来决定文本的方向,@符合属于弱字符,不能影响后续文本的方向。
en 环境

分析,@属于弱字符,
ar 环境

`
android:textDirection="locale"
en 环境

ar 环境

android:textDirection="rtl"
Mashi 中@根据第一个内容来处理方向
/**
* 不可见字符
*/
const val CHAR_INVISIBLE = "\u0001"
/**
* Unicode "Left-To-Right Embedding" (LRE) character.
*/
const val LRE = "\u202A" // LTR
/**
* Unicode "Right-To-Left Embedding" (RLE) character.
*/
const val RLE = "\u202B" // RTL
const val LRO = "\u202D"
const val RLO = "\u202E"
const val PDF = "\u202C"
// 不可见字符”\u200b”为 Unicode Character ‘ZERO WIDTH SPACE’ (U+200B),可用于内容标识,不占位数。
const val CHAR_ZERO_WIDTH_SPACE = "\u200B"
const val CHAR_AT = "@"
// RoomAtMessage
class RoomAtMessage : RoomChatBaseMessage() {
@SerializedName("c")
var content: RoomAtBean? = null
companion object {
const val TAG = "at"
fun createAtMessage(atUser: MutableList<User>, fromUser: User, textContent: String, atColor: String = "#00FFC8"): RoomAtMessage {
val message = RoomAtMessage()
message.content = RoomAtMessage().RoomAtBean()
message.content?.atColor = atColor
message.content?.at_users = atUser
message.content?.text = textContent
message.user = fromUser
message.messageType = BaseRoomMessage.ROOM_COMMON_AT_TYPE
var localMsgId = System.nanoTime()
if (localMsgId < 0) {
localMsgId = -localMsgId // 保证得到的是正数
}
message.messageId = localMsgId
return message
}
}
override fun toString(): String {
val namesWithUnicode = content?.at_users?.joinToString(separator = ",", transform = {
val name = it.name ?: ""
"$name(${name.string2Unicode()})"
})
return "[${namesWithUnicode}]\t\t${GsonUtils.toJson(this)}"
}
@Keep
inner class RoomAtBean {
@SerializedName("at_users")
var at_users: List<User>? = null
@SerializedName("at_color")
var atColor: String? = null
var text: String? = null
override fun toString(): String {
return "【text=$text,at_color=$atColor,at_users=$at_users】"
}
@ColorInt
private fun getAtColorInt(): Int {
try {
if (atColor?.contains("#") != true) {
atColor = "#$atColor"
}
return Color.parseColor(atColor)
} catch (e: Exception) {
e.printStackTrace()
}
return ResUtils.getColor(R.color.white)
}
fun getAtUsers(): List<User> {
return if (at_users.isNullOrEmpty()) {
emptyList()
} else {
at_users!!
}
}
/**
* at消息,取第一个内容作为全局方向判断
*/
private fun getRtlContext(): Boolean {
val temp = text ?: return false
val b = temp.length > CHAR_INVISIBLE.length + 1
// 存在任何RTL字符认为是RTL
val isRtlContext = if (b) TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR.isRtl(temp, CHAR_INVISIBLE.length + 1, 1) else false
LogUtils.i(TAG, "${anchor("getRtlContext")}rtlContext=$isRtlContext,[原始][$temp],temp length=${temp.length}")
return isRtlContext
}
private fun getAtUserNames(): List<String> {
return if (at_users.isNullOrEmpty()) {
emptyList()
} else {
val names = LinkedList<String>()
at_users?.forEachIndexed { index, user ->
names.add(user.getAtName(index, getRtlContext()))
}
names
}
}
private fun getWrapperUnicodeName(name: String, rtlContext: Boolean, isFirstAt: Boolean = false): String {
return if (rtlContext) {
"${RLO}$name $PDF"
} else {
"$LRO$name $PDF"
}
}
private fun getSpanText(): String {
if (text.isNullOrBlank()) {
return ""
}
val atUserNames = getAtUserNames()
if (atUserNames.isNullOrEmpty()) {
return text!!
}
var temp = text!!
for (index in atUserNames.indices) {
val atUserName = atUserNames[index]
// 输入框@人展示规则根据已输入的字符和用户名首字母来确定 by xulinag
// val isHasRtl = TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR.isRtl(atUserName, 0, 1)
// temp = temp.replaceFirst(CHAR_INVISIBLE, getWrapperUnicodeName(atUserName, getRtlContext() || isHasRtl, index == 0))
temp = temp.replaceFirst(CHAR_INVISIBLE, getWrapperUnicodeName(atUserName, getRtlContext(), index == 0))
}
LogUtils.i(TAG, "${anchor("getSpanText")}[替换后]temp=$temp")
return temp
}
fun bindText(textView: TextView, bubble: StoreGoodsPreview?, clickAction: (User.() -> Unit)? = null) {
val linkBuilder = LinkBuilder.from(GlobalContext.getAppContext(), getSpanText())
val atUsers = at_users ?: emptyList()
for (index in atUsers.indices) {
val user = atUsers[index]
linkBuilder.addLink(Link(user.getAtName(index, getRtlContext()))
.setOnClickListener {
clickAction?.invoke(user)
LogUtils.i(TAG, "${anchor("bindText")}onClick uid=${user.id},it=$it")
}
.setUnderlined(false)
// .setTextColorOfHighlightedLink()
.setTextColor(bubble?.atColor() ?: getAtColorInt()))
}
val cs = linkBuilder.build()
textView.text = cs
LogUtils.i(TAG, "${anchor("bindText")}$cs")
textView.movementMethod = TouchableMovementMethod.instance
}
}
}
// User
class User {
/**
* \@消息用户name 添加特殊字符和双向控制字符
* @param index 用于添加U+200B特殊字符,解决@消息相同名字点击跳转问题
* @param isRTLContext 当前一段文字的全局方向,true是RTL,否则LTR
* @return
*/
public String getAtName(int index, boolean isRTLContext) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < index; i++) {
sb.append(CHAR_ZERO_WIDTH_SPACE);
}
sb.append(CHAR_AT);
String unicodeWrapName = BidiFormatter.getInstance(isRTLContext).unicodeWrap(name);
sb.append(unicodeWrapName);
return sb.toString();
}
}
Support BiDi(双向字符集) 显示
- Android RTL 布局和双向字符集显示
https://segmentfault.com/a/1190000003781294#item-3-1 - 英文,波斯文,阿拉伯数字混排
- https://weiwangqiang.github.io/2018/12/10/Android-rtl/#5英文波斯文阿拉伯数字混排
国际化语言适配
APP 内只内置部分可用的语言,其他的语言都放在后台,用多语言更新用 Gradle task 每次动态的下载下来放到 res 目录下去
// 下载多语言zip包用的插件
apply plugin: 'de.undercouch.download'
/**
* 下载开发版语言包
* 开发阶段,运行这个任务导入多语言
*/
task updateDevelopLanguages(group: 'language') {
doLast {
updateLanguageResources(3, true)
}
}
/**
* 下载灰度语言包
* 灰度阶段,由测试生成语言包之后,运行这个任务导入多语言
*/
task updateBetaLanguages(group: 'language') {
doLast {
updateLanguageResources(1,true)
}
}
/**
* 下载最终版语言包
* 多语言测试完成后,测试在后台生成最终语言包。
* 发布阶段,由测试生成语言包之后,运行这个任务,更新多语言
*/
task updateReleaseLanguages(group: 'language') {
doLast {
updateLanguageResources(2,true)
}
}
/**
* jenkins job执行这个
*/
task updateJenkinsBetaLanguages(group: 'language') {
doLast {
updateLanguageResources(1,false)
}
}
//https://wiki.dotfashion.cn/pages/viewpage.action?pageId=735548708
// 1.下载并解压语言包
//2.替换多语言资源
//3.删除临时文件
//releaseType 1=灰度发布; 2=正式发布;3=开发包;
// downloadZip 是否下载zip包,jenkins是用gitlab的zip包
def updateLanguageResources(int releaseType, boolean downloadZip) {
def packageInfo = downloadLanguageFileAndUnZip(releaseType, downloadZip)
//遍历找到项目中的string.xml文件
//找到对应解压出来的多语言文件
//用解压包里的文件覆盖原文件
println "开始更新文件"
def basicPath = rootDir.parent + "/si_strings_android"
def tempDir = new File(basicPath, "temp_data")
def resDir = new File(basicPath + "/si_strings/src/main/res")
def resChildDirs = resDir.listFiles()
def valuesDirIterator = resChildDirs.iterator()
def updatedFileNum = 0
while (valuesDirIterator.hasNext()) {
def valuesDir = valuesDirIterator.next()
if (valuesDir.isDirectory()) {
def valueDirName = valuesDir.name
if (!valueDirName.startsWith("values")) {
continue
}
def strFiles = valuesDir.listFiles()
def valuesFileIterator = strFiles.iterator()
File targetFile = null
while (valuesFileIterator.hasNext()) {
def valuesFile = valuesFileIterator.next()
if (valuesFile.isFile() && valuesFile.name == "strings.xml") {
targetFile = valuesFile
break
}
}
if (targetFile != null) {
//查找解压后的语言是否有这个
def tempChildDirs = tempDir.listFiles()
def tempFileIterator = tempChildDirs.iterator()
File tempFile = null
while (tempFileIterator.hasNext()) {
def tempChildDir = tempFileIterator.next()
if (tempChildDir.isDirectory()) {
def tempDirName = tempChildDir.name
if (tempDirName == valueDirName) {
def tempFiles = tempChildDir.listFiles()
def tempFilesIterator = tempFiles.iterator()
while (tempFilesIterator.hasNext()) {
def fileItem = tempFilesIterator.next()
if (fileItem.isFile() && fileItem.name == "strings.xml") {
tempFile = fileItem
break
}
}
break
}
}
}
if (tempFile != null) {
println ""
println ""
copy {
from tempFile
into valuesDir
}
println "临时文件路径:" + tempFile.path
println "覆盖的文件路径" + targetFile.path
println "---------更新文件完成" + (updatedFileNum + 1) + "-----------"
updatedFileNum++
}
}
}
}
println "是否删除了临时文件?" + tempDir.deleteDir()
println "String更新完成,更新了" + updatedFileNum + "个文件"
println "$packageInfo"
}
//多语言文件下载任务及解压任务
def downloadLanguageFileAndUnZip(int releaseType,boolean downloadZip) {
println "downloadLanguageFileAndUnZip..."
def basicPath = rootDir.parent + "/si_strings_android"
def zipFileStr = ""
def versionInfo = ""
def dateInfo = ""
def zipFile = ""
if (downloadZip) {
println "查询语言包..."
/*
def url = 'https://b2c-admin.biz.sheincorp.cn/language/all_multi_lang/get_front_lang_export_pc?device_type=1&platform_type=1&device=2'
def req = new URL(url).openConnection()
def responseCode = req.getResponseCode()
println "响应状态:Status code=$responseCode"
if (responseCode == 301) {
String newUrl = req.getHeaderField("Location")
println "301重定向 url=$newUrl"
req = new URL(newUrl).openConnection()
}
logger.quiet "响应状态:Status code=${req.getResponseCode()}"
def result = req.getInputStream().getText()
def resp = new groovy.json.JsonSlurper().parseText(result)
def info = resp.info
*/
def url = 'https://language-config-center-api.biz.sheincorp.cn/lang/release/get-file-url'
def req = new URL(url).openConnection()
req.setRequestProperty("authorization", "JQb9R2WftOrWuAP2vgpT7BFlKSBGZSz8lAuxXh6gJVONev6bG2VHODDsYAId8RUxwhydUEQsVC+89Ccn8/BgUfP4Mnu/q2m9rusi2kDc4XfS3R3iNt1HGCHxeSZ99Q0J/cuPGvBzn3V5ewjReWIK6jBkZ9825dtNYrwEwzn/vuY=")
req.setRequestProperty("Content-Type", "application/json")
req.setDoOutput(true)
def outputStream = req.getOutputStream()
def writer = new OutputStreamWriter(outputStream)
writer.write("{\"platform\": 1, \"deviceType\": 1, \"releaseType\": $releaseType}")
writer.flush()
println ""
logger.quiet "响应状态:Status code=${req.getResponseCode()}"
def result = req.getInputStream().getText()
def resp = new groovy.json.JsonSlurper().parseText(result)
def info = resp.info
def releaseTime = Long.parseLong("${info.releaseTime}") * 1000L
versionInfo = "${info.version}"
logger.quiet "语言包版本: $versionInfo"
def packageUrl = info.fileUrl
def date = new Date(releaseTime)
def format = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
dateInfo = format.format(date)
logger.quiet "语言包下载链接: $packageUrl"
logger.quiet "语言包发布时间: $dateInfo"
println ""
println "..开始下载语言包..."
zipFile = new File(basicPath, 'language.zip')
if (zipFile.exists()) {
zipFile.delete()
}
download {
src packageUrl
dest zipFile
}
zipFileStr = zipFile.path
println "语言包下载完成"
} else {
println "jenkins 设置zip语言包zipFile..."
zipFileStr = "/Users/CI/Desktop/workspace/APP_AUTO_TASK/MULTILINGUAL_TASK/UPDATE_ALL_APP_MULTILINGUAL/si_app_language/shein-Android.zip"
}
println ".....$zipFileStr"
def tempDir = new File(basicPath, "temp_data")
def delete = tempDir.deleteDir()
println "是否清空了临时目录?" + delete
println ""
tempDir.mkdir()
def fileTree = zipTree(zipFileStr)
def files = fileTree.files
println "语言包文件数量:${files.size()}"
files.forEach({
file ->
def parentFile = file.parentFile
def name = parentFile.name
copy {
from file
into "${tempDir.path}${File.separator}${name}"
}
})
println "语言包已解压到" + tempDir.path
if (downloadZip) {
def zipDeleted = zipFile.delete()
println "删除语言包?" + zipDeleted
}
println ""
println "语言包下载解压完成"
return "语言包版本: $versionInfo 发布时间:$dateInfo"
}