模拟键盘输入优化

𖭀 

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

预测字母并扩大其触发面积

当前实现


伪代码警告
有个二维数组描述按键位置:
js 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 把按钮都放进去:
js {POS.map((linePos, lineIndex) => <div className={style.line} key={lineIndex}> {linePos.map((letter) => ( <Key key={letter} letter={letter} /> ))} </div> )}

Key 组件用于聚合操作:
js 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:
字母t单独出现的概率是8.13%,在s后的概率是21.18%,在en后的概率是48.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’ 之后的结果:
re后出现s的概率是30.66%,t的概率是1.09%

最后适当的修改这些字符触发面积即可:
根据上面的值,修改触发面积

收尾

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

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

所以统计数值就变成了:

概率值不重要,概率顺序很重要

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

所以稍微改一改代码:

  // 辅助函数:将排序字符串转成带权重对象(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,好多了。


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

{
  "unigram": "eiatrnoslcupdmghybfvwkxqzj", // 此乃字母开头概率顺序
  "bigram": {
    ...
    "q": "u", // 意味着四六级单词中,q 后面只能跟 u
    ...
  },
  "trigram": {
    ...
    "bj": "e", // 类似的,这些组合后面只会跟某一个字母
    "hd": "r",
    "hs": "t",
    "gd": "o",
    "wt": "h",
    ...
  }
}