启达星 Flutter 客户端一二面:TLS 握手、RRF 融合与瓷砖铺放动态规划
- 轮次
- 一二面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一面(9.8,52min):
- 请做个人介绍,并说明为什么用偏 C++ 的简历投递 Flutter 客户端岗位
- HTTP 与 HTTPS 有什么区别?TLS 握手、证书校验和会话密钥怎样工作?
- 哈希表的查询复杂度、冲突处理和负载因子分别是什么?
- 拉链法哈希表扩容时为什么需要重新计算桶位置?
- Release 构建开启 R8/ProGuard 后为什么可能出现反序列化异常?
- 阅读 Kotlin 消息合并器代码,说明它的职责、并发风险和生命周期问题
- 消息合并器如何同时满足「累计三条」和「等待 900ms」两种刷新条件?
- RAG 多路召回包含哪些检索路径?RRF 怎样融合排名?
- RRF 中 k=60 的来源是什么?怎样通过评测确定参数?
- 是否使用过 Figma?怎样适配大中小屏、平板和折叠屏?
- 日常怎样使用 Claude Code、Codex 或其他 AI 工具辅助开发并做人工 Review?
- 两个链表按高位到低位表示大数,且不允许修改输入,怎样完成相加?
- 反问:Flutter 与原生能力的边界、产品业务阶段和入职后负责的模块
- 反问:低端机上的启动、UI、内存和稳定性怎样治理?
二面(9.10,39min): 15. 请概括快手实习经历,并说明目前能否继续实习 16. 快手「天团」业务的入口、用户场景和完整数据链路是什么? 17. 这里所说的乱序具体表现是什么?顺序由什么业务规则定义? 18. 如果消息来自同一有序消息源,为什么仍会乱?多个异步请求之间为何存在顺序关系? 19. 900ms 聚合窗口结束时前置消息仍未到达,界面应怎样降级和补齐? 20. ConcurrentHashMap 缓存的 Key、Value 和生命周期分别是什么? 21. ConcurrentHashMap 能否保证「查询—请求—写入」整条复合流程的原子性? 22. Cache-Aside 与 Stale-While-Revalidate 分别是什么?哪些数据不适合展示旧值? 23. 用 2×1 瓷砖铺满 2×N 区域共有多少种方案?动态规划状态和递推式怎样定义? 24. 如果 N 很大,能否把计算复杂度从 O(n) 降到 O(log n)?
《参考解析》
TLS 握手可以按「四个目标」讲:协商算法、验证身份、交换密钥、建立对称加密通道。 以 TLS 1.2 的 ECDHE 为例:ClientHello(支持的版本、密码套件、随机数、支持的曲线)→ ServerHello + 证书 + ServerKeyExchange(含 ECDHE 公钥与签名)+ ServerHelloDone → 客户端验证证书链(是否由受信 CA 签发、签名是否有效、域名是否匹配、是否过期/吊销)并生成自己的 ECDHE 公私钥,双方各自算出同样的共享密钥(预主密钥),再派生出会话密钥(主密钥 + 密钥块)→ 双方交换 Finished 验证握手完整性,之后用对称加密(AES-GCM/ChaCha20)通信。要点:证书校验失败必须硬失败,不能降级;ECDHE 提供前向保密(私钥泄露也解不了历史流量);TLS 1.3 把握手压缩到 1-RTT(甚至 0-RTT 复用),去掉了 RSA 密钥交换与一堆弱套件,把证书加密传输。
哈希表三问。 查询平均 O(1)、最坏 O(n)(所有 key 冲突成一条链);冲突处理主流是拉链法(每个桶挂链表/红黑树,Java 8 起链表长度超 8 转红黑树)和开放寻址(线性/二次探测、双重散列,缓存友好但易聚集、删除需要墓碑标记)。负载因子 = 元素数 / 桶数,越大冲突越多、越小越浪费空间,Java HashMap 默认 0.75,是时间与空间的折中经验值。扩容时为什么要重算桶位置:容量变化后取模的除数变了。JDK 的 HashMap 容量总是 2 的幂,用 hash & (n-1) 代替取模,扩容到 2 倍时只需要看哈希值多出来的那一位:该位为 0 留在原下标,为 1 移到 原下标 + 旧容量,所以可以把一条链拆成两条、不重新计算哈希(这是 JDK 8 的优化,避免每次 rehash 都重算 hash)。
R8/ProGuard 之后反序列化异常,根因是「反射依赖的名字被混淆了」。 大多数 JSON 库(Gson、Fastjson、Jackson)通过反射按类名与字段名创建对象和赋值,混淆后字段名变成 a、b,而 JSON 里的键还是原名,于是赋不上值或报「no such field / cannot construct」;泛型信息也会因类型擦除 + 混淆而丢失(List<Foo> 变成 List<Object>)。解法:在 proguard-rules.pro 里保留模型类的字段与无参构造(-keep class com.x.model.** { *; },或更精细地保留 @SerializedName 注解与构造器)、保留 Parcelable/Serializable 相关成员、保留枚举,或改用带注解处理器/代码生成的序列化方案(如 Moshi codegen、Kotlinx Serialization),后者在编译期生成序列化器,天然免疫混淆——但要注意 Kotlinx Serialization 的 @Serializable 类也要保留伴生对象。排查手法:保留 mapping 文件并上传符号表,用 retrace 还原堆栈,能直接看到被混淆前的类名。
「累计三条或等待 900ms」是典型的「双条件触发」合并器。 实现上要同时维护计数与定时器:每来一条消息 count++ 并重置/启动一个 900ms 的定时器,当 count >= 3 立即 flush,或者定时器到期时无论多少条都 flush;flush 后清零并取消定时器。并发风险:如果消息来自不同线程(或协程),计数与缓冲区的读写必须同步(加锁或单线程化到同一个 Dispatcher/Channel);生命周期问题:定时器与协程要绑定组件生命周期,在 onDestroy/取消作用域时释放,否则会泄漏并在界面销毁后触发回调;另一个坑是重入——flush 过程中新消息到达要正确处理(要么进下一个批次,要么合并处理,不能丢)。追问「900ms 结束时前置消息仍未到达怎么办」:要么按现有内容先渲染、到达后按序列号做二次规整(插入或修正),要么把窗口内缺号的消息标记为「待补齐」并用占位符,绝不能等(会把整页卡住)。这就要依赖业务定义的顺序规则(按序列号还是按服务端时间)与可插入的数据结构。
RRF 的 k=60 与多路召回。 RRF(Reciprocal Rank Fusion)把每一路检索结果按排名倒数求和:score(d) = Σ 1/(k + rank_i(d)),只用名次不用原始分数,因此天然解决「不同检索器分值不可比」的问题(BM25 的分数区间与向量余弦完全不同)。k 的作用是压平头部优势:k 越小,第一名(rank=1)与其他名次差距越大、越独裁;k 越大,各路贡献越平均。60 是原论文(Cormack 等,2009)在 TREC 数据上实验得出的经验值,被广泛沿用,但它不是普适最优——正确做法是把它当成超参,在自己的评测集上用网格搜索/贝叶斯优化,评价指标用 NDCG@10 或 MRR(而不是只看召回),并注意要固定候选集大小与各路召回数量,否则对比不公平。工程上还要说明:融合之后通常再接一个 cross-encoder 重排(融合负责召回率、重排负责精度),以及每路的权重(有些系统给向量检索更高权重)也可以一起调。
瓷砖铺放问题是经典动态规划。 用 2×1 瓷砖铺 2×N 区域,方案数就是斐波那契数列:设 f(n) 为铺满 2×n 的方案数,考虑最左边一列——竖放一块占 1 列(剩下 2×(n-1)),或者两块横着叠放占 2 列(剩下 2×(n-2)),所以 f(n) = f(n-1) + f(n-2),边界 f(1)=1、f(2)=2;时间 O(n)、空间可优化到 O(1)。能不能从 O(n) 降到 O(log n):能,因为这是线性递推,用矩阵快速幂 [[1,1],[1,0]]^(n-1) 在 O(log n) 时间内算出第 n 项(还有快速倍增法;理论上可用 Binet 公式但它涉及浮点精度与大数,Python 可以,其他语言不推荐)。要补一句实际约束:答案会指数级增长,必须取模或用大整数,递推要防止溢出。
Cache-Aside 与 Stale-While-Revalidate 的区别是「读旧值的容忍度」。 Cache-Aside 是最常见的模式:读时先查缓存,未命中查数据库再回填;写时先更新数据库再删除缓存(不是更新,避免并发写导致脏数据),并配过期时间兜底。Stale-While-Revalidate 是「先返回旧值,同时后台异步刷新」:缓存里存逻辑过期时间,过期后立即返回旧值并触发一个后台刷新任务,用户感知延迟极低,但会读到短暂过期的数据。不适合展示旧值的数据:资金余额与支付状态、库存、权限与登录态、订单状态、以及任何「旧值会导致错误决策或资损」的字段——这些必须强一致(读时校验或直接查库)。ConcurrentHashMap 那一问的答案是:它只保证单个操作原子(putIfAbsent、computeIfAbsent、compute),「查询—不存在则请求下游—写入」这条复合流程不是原子的,两个线程会同时穿透去请求下游(缓存击穿);要保证整体只执行一次,需要用 computeIfAbsent 把请求塞进映射函数(注意映射函数内不能做长时间阻塞操作,它会锁住该桶)、或加单飞(singleflight)/分布式锁,并把「请求中」占位写进缓存避免重复回源。