文章

多语言适配

多语言适配

bidi 算法(双向字符)、BidiFormatter 详解及 mashi 适配案例

Bidi 基础

双向字符类型

书写方向是和文字相关,阿拉伯文字从右到左,拉丁文字从左到右。当人们在纸上书写时当然会记得这些规则,那计算机是如何知道的呢?实际上,Unicode 定义了它其中每个字符的方向属性,计算机就是根据这个方向属性来判断该文字的方向。
Unicode 方向属性包含三种类型:强字符、弱字符和中性字符

强字符

大部分的字符都属于强字符。它们的方向性是确定的,从左到右或者从右到左,和其上下文的 bidi 属性无关。并且,强字符在 bidi 算法中可能会影响其前后字符的方向性

  1. 左到右(LTR)
    拉丁文字 (英文字母)、汉字
  2. 右到左(RTL)
    RTL 语言有以下 6 种:
语种语言代码国家示例
阿拉伯语arArbicالعربية
波斯语faPersianفارسی
希伯来语iwHebrewעברית
乌尔都语(印度、巴基斯坦)urUrduاردو
维吾尔语-Uyghur-

弱字符

弱字符的特性,它们的方向是确定的,但对其前后字符的方向性并不会产生影响。数字和数字相关的一些符号就属于弱字符。

  1. 西阿拉伯数字 (LTR):(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
  2. 东阿拉伯数字:(٠‎ - ١‎ - ٢‎ - ٣‎ - ٤‎ - ٥‎ - ٦‎ - ٧‎ - ٨‎ - ٩)
  3. 波斯数字:(۰ - ۱ - ۲ - ۳ - ۴ - ۵ - ۶ - ۷ - ۸ - ۹)
  4. 其他有一些语言也有自己的数字.

中性字符

中性字符的方向性是不确定的,由上下文的 bidi 属性来决定其方向,如 android:textDirection

  1. 段落分隔符制表符 和大多数其他 空格字符
  2. 比如大部分的标点符号,%@.+-[]()空格 等。
  3. $ 是强方向字符,左到右

案例显示:

1
<string name="plus_user_ar">+ يوم</string>

在 as 中显示:4yqsn 手机显示效果:

  1. android:textDirection="ltr"
    8lxnx
  2. android:layoutDirection="locale"
    8z1gw

特殊字符的类型

Unicode 控制字符方向 (Unicode bidi support)

大部分情况,Unicode 双向算法能根据字符属性和全局方向等信息运算并正确地显示双向文字,这是该算法的隐性模式。在这种模式下,双向文字的显示方式基本上由算法完成,不需要人为的干预。但是,隐性模式的算法在处理复杂情况的双向文字时会显得不足,这时就可以使用显性模式来进行补充。在显性模式的算法中,除了隐性算法的运算外,可以在双向文字中加入关于方向的 Unicode 控制字符来控制文字的显示。这些被加入文字中的 Unicode 控制字符在显示界面上是不可见的,也不占用任何显示空间。它们只是在默默地影响着双向文字的显示。

隐性双向控制字符

1
2
U+200E:   LEFT-TO-RIGHT MARK (LRM)
U+200F:   RIGHT-TO-LEFT MARK (RLM)

您可以将这类的控制字符看成是不会显示出来的强字符,LRM 为从左到右的强字符,而 RLM 为从右到左的强字符。
代码用: \u200E

显性双向控制字符

这类控制字符需要成对使用,列表中的前四个为开始字符,而最后一个为结束字符。

1
2
3
4
5
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 实例化入口开始看:

1
2
3
public static BidiFormatter getInstance() {
    return new Builder().build();
}

BidiFormatter 实例化用了 Builder 模式,再看 Builder() 默认值

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
当前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);
    }
}

小结:

  1. BidiFormatter 默认的全局方向 mIsRtlContext 是根据当前 Locale 才决定的,阿语环境是 RTL,中/英语环境是 LTR
  2. mTextDirectionHeuristicCompat 方向推理器默认为 DEFAULT_TEXT_DIRECTION_HEURISTIC

DEFAULT_TEXT_DIRECTION_HEURISTIC 是什么?

1
2
3
4
5
6
// 默认方向推理器
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 很多重载的方法,比如常用的

1
2
3
public String unicodeWrap(String str) {
    return unicodeWrap(str, mDefaultTextDirectionHeuristicCompat, true /* isolate */);
}

最终都是走到这里:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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 有检测

原因

1
java.lang.IllegalArgumentException: Unexpected char 0x660 at 15 in Tz value: Asia/Shanghai,+٠٨:٠٠

eipmu

HTTP header 非 ASCII 码不能作为 header name/value,OKHttp 有检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 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 来格式化

1
2
3
4
5
6
7
8
9
10
11
12
@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 发送某些特殊字符崩溃

特殊字符:

1
_ヽ   \\ Λ_Λ    \( 'ㅅ' )     > ⌒ヽ    /   へ\    /  / \\    レ ノ   ヽ_つ   / /   / /|  ( (ヽ  _ヽ   \\ Λ_Λ    \( 'ㅅ' )     > ⌒ヽ    /   へ\    /  / \\    レ ノ   ヽ_つ   / /   / /|  ( (ヽ  ⊂_ヽ   \\ Λ_Λ    \( 'ㅅ' )

崩溃日志:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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>

原因:

在阿语环境下,这些字符会引起崩溃;非阿语不会崩溃

1
聊天消息和昵称以及其他一些徽章图片等都是在同一个textview里面,通过spannable来进行排版的。阿拉伯字母的出现打乱了spannable顺序,引起了这次事故

解决 1:
TextView 和 EditText 需要加上,似乎没有用

1
2
android:textDirection="ltr"
android:textAlignment="viewStart"

解决 2:先 try catch,有问题换掉一些空格,再调用。

Mashi 这样处理的,具体看线上情况

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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

阿语英文混编

BidiFormatter

双方向字符摆放,插入控制字符,来保证段落字符的正确摆放

  1. 根据给定的 TextDirectionHeuristicCompat,推断出给定文本的方向,插入 bidi 控制字符
  2. 当前的文本的方向和全局方向相反的,会插入对应的 bidi 字符,让这段文本显示正确

en 环境的全局方向为 LTR;ar 环境全局方向为 RTL

1
2
3
4
5
6
7
8
9
隐性双向控制字符:
    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 环境

  1. 默认行为,从左到右显示,英文在前,阿语在后
  2. BidiFormatter 默认行为,是根据首字母来确定该段文本方向,首字母英文,那该段文本的方向为从左到右,显示同 1.默认行为
  3. BidiFormatter ANYRTL_LTR 行为,有任何 RTL 字符就确定为 RLT 方向,所以该段文本从右到左显示

ar 环境

kqwe4

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

en

sqsw4

ar

wfcf3

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

en

rpv2j

ar

me33o

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

en

8vz0s

ar

uo5tm

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

en

7knqi

ar

y7bou

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

en

dzbp3

ar 环境

p5tl4

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

en

4r2j4

ar

37r7v

这里显示不正确,en 和 ar% 都没有跟在数字后面

双方向字符案例

购物车文案显示(- 开头显示错误)

ze60n

RTL 下,- 号跑到了右边,期望在左边

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<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")

bh9a7

7 天 + 显示

v80be!

第二个和第四个效果显示正确

1
2
3
<string name="one_day">‫%dيوم</string>
<string name="more_days">‫%dيوم</string>
<string name="last_days">‫%dيوم+‫</string>

在前面加上 \u202B 特殊字符

@用户名和内容,中英/阿语混编问题同 WhatsApp 一样处理

英语指非阿语,含英语、中文等所有从左往右的语言
系统语言不干涉用户用户文本框内容,只影响页面布局左右排版
dd1rd

测试

1
2
3
4
5
6
val s1 = "@%s 你好啊."
val s2 = "@%s فارسی."
// 3种name
val name_en = "hacket" // 英文
val name_ar = "ماجكوو" // 阿语
val name_ar_en = "♠️⁩⁦♨️⁩༆㋡⃢ماجكوو🤣༗" // 阿语符号混合

TextView 默认

hdsnt

android:textDirection="firstStrongRtl/firstStrongLtr/firstStrong"

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

en 环境

v9tb9

分析,@属于弱字符,

ar 环境

bvhr8
`

android:textDirection="locale"

en 环境

wsg0w

ar 环境

x8pbc

android:textDirection="rtl"

Mashi 中@根据第一个内容来处理方向

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
 * 不可见字符
 */
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(双向字符集) 显示

国际化语言适配

APP 内只内置部分可用的语言,其他的语言都放在后台,用多语言更新用 Gradle task 每次动态的下载下来放到 res 目录下去

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
// 下载多语言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.zzz.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.xxx.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://xxx.cn/lang/release/get-file-url'
        def req = new URL(url).openConnection()
        req.setRequestProperty("authorization", "xxx")
        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 = "xxx.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"
}
本文由作者按照 CC BY 4.0 进行授权