~
 Gather ye rosebuds while ye may

模拟键盘输入优化

𖭀 

最近又看到用户反馈里有人喷咱这个模拟键盘输入体验太差,这正好有空就给他优化了。

an example of prediction

当前实现


伪代码警告
有个二维数组描述按键位置:
1
2
3
4
5
const POS = [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["z", "x", "c", "v", "b", "n", "m"],
];


一个 map 把按钮都放进去:
1
2
3
4
5
6
7
8
9
10
11
{POS.map((linePos, lineIndex) =>
<div className={style.line} key={lineIndex}>
{linePos.map((letter) => (
<Key
key={letter}
letter={letter}
/>
))}
</div>

)}


Key 组件用于聚合操作:
1
2
3
4
5
6
7
8
9
10
const Key = () =>
<Click
className={style.key}
debounce={14}
onTouchStart={() => {
onInput(letter);
}}
>
{letter}
</Click>

现有问题

虽然用户也没办法准确描述自己遭遇的「不好用」具体指什么,但大概应该可以总结为「想输入某字符的时候点击了按钮,但上屏的是另一个字符」。冲突点在于「我以为我点了A」和「实际上屏了B」。

相对应的解决方案也显而易见:
一,在上屏 B 的时候让用户知道自己点了 B;
二,准确的上屏用户想点的字符 A;

优化方向

观察原生键盘,其实这两个优化方向早就已经实装运用了:
一,iOS 键盘在触摸的时候会有更大的字符展示在已点击字母的上方;
二,根据用户输入习惯、语法进行预测;

方案一其实稍微改改 Key 组件,在用户按下的时候展示新样式就行。
方案二呢?

输入预测

鉴于目前的输入场景是单词拼写,输入内容和上下文基本无关,反倒是和用户已掌握词汇量强相关–毕竟人只会输入掌握了的词–而不是语法语态、或是用户的常用表达习惯。

以字母为单位,N-gram 处理这个场景就很合适:

对用户掌握的词表进行统计,获得不同字母组合下新字母出现的概率。再根据输入匹配,附以不同权重汇总最终概率,即预测输入。

所以准备了一份四六级单词表去重后统计 0-1-2 字符 N-gram:
ngram

用以匹配用户输入(的末尾),计算出下一个字符出现的概率:

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
function interpolateFromSortedNgram({
context,
lambdas = [0.6, 0.3, 0.1],
}: {
context: string;
lambdas?: number[];
}) {
const result: Record<string, number> = {};
const seen = new Set();

// 辅助函数:汇总到 result
const decode = (record: Record<string, number>, lambda: number) => {
if (!record) return;
for (char of record) {
const weight = record[char]
result[char] = (result[char] || 0) + weight;
seen.add(char);
}
};

// 提取上下文
const triKey = context.slice(-2);
const biKey = context.slice(-1);

decode(NGRAM.trigram[triKey], lambdas[0]);
decode(NGRAM.bigram[biKey], lambdas[1]);
decode(NGRAM.unigram, lambdas[2]);

// 归一化权重(确保总和为 1)
const total = Object.values(result).reduce((a, b) => a + b, 0);
for (const char of seen) {
result[char] = +(result[char] / total).toFixed(4);
}

return result;
}

输入 ‘RE’ 之后的结果:
prediction after typing RE

最后适当的修改这些字符触发面积即可:
an example of prediction

收尾

这份 ngram 统计结果跑出来有 76KB,当然我不想为每个用户都增加这么大的文件。所以考虑另一种方案:具体字母概率不重要,在权重的影响下只需考虑可能出现的字母概率顺序。

讲人话:当前场景并不需要对预测进行排序,而是需要尽可能预测出几个字母,进行触发区放大。

所以统计数值就变成了:

ordered ngram

还可以加上:如果用户正在输入标准答案,则提高下一字母的概率。

所以稍微改一改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  // 辅助函数:将排序字符串转成带权重对象(1/i 映射)
const decode = (str: string, lambda: number) => {
if (!str) return;
for (let i = 0; i < Math.min(topN, str.length); i++) {
+ const char = str[i];
+ const weight = lambda * (1 / (i + 1));
+ result[char] = (result[char] || 0) + weight;
+ seen.add(char);
}
};

...

// 提升输入标答的权重
+ if (context.length && word.indexOf(context) === 0) {
+ const nextLetter = word[context.length];
+ result[nextLetter] = 0.8;
+ }

而这份 ngram2.json 只有 8KB,好多了。


俺这儿只是对特定场景造轮子,对比有专业团队长时间维护的原生键盘,颇有种班门弄斧的感觉,专业的事情确实还得专业的人做。
倒是从统计数据里看到一些有意思的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"unigram": "eiatrnoslcupdmghybfvwkxqzj", // 字母开头概率顺序
"bigram": {
...
"q": "u", // q 后面只能跟 u
...
},
"trigram": {
...
"bj": "e", // 类似的,这些组合后面只会跟某一个字母
"hd": "r",
"hs": "t",
"gd": "o",
"wt": "h",
...
}
}