原生UI开发中的那些坑和实用优化技巧

W″星星 移动 阅读 1,008
赞 35 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这次做的是一款移动端的资讯类App,主要功能是浏览文章、视频播放、评论互动这些。原本打算用React Native + 自定义组件库搞定,毕竟团队熟这个。但产品提了个需求:希望首页滑动要像原生App那样顺滑,尤其是上下滑切换Tab的时候,不能有卡顿或者延迟感。

原生UI开发中的那些坑和实用优化技巧

开始没想到这会是个坑。试了几个轮子,比如react-native-tab-view,效果还行,但手指松开那一瞬间总有那么一点点“脱手”的延迟感,用户反馈说像是在“等页面跟上”。后来我们拿竞品对比,人家丝滑得不行,我们的就像老车换挡——能用,但不爽。

最后决定上原生UI。iOS用UIKit写了个自定义容器视图,Android那边让同事上了MotionLayout配合NestedScrollView。核心思路是:把最外层的大Tab切换交给原生处理,React Native只负责每个Tab内部的内容渲染。通过桥接通信,传递当前激活的Tab索引和滚动状态。

最大的坑:touchmove滚动失效

集成之后第一个大问题就来了:WebView里的内容可以滚动,但手势完全被外层原生View拦截了。一开始以为是事件冲突,加了shouldRecognizeSimultaneouslyWithGestureRecognizer还是不行。折腾了半天发现,iOS这边UIScrollview嵌套导致子级RN的ScrollView根本收不到touchmove事件。

查了一堆文档,最后用了个偏方:在原生侧判断手势方向,如果是垂直滑动且在顶部或底部边界时,才把事件往下传。中间区域一律由原生处理。代码大概是这样:

-c
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    if ([otherGestureRecognizer.view isKindOfClass:[UIScrollView class]]) {
        UIScrollView *scrollView = (UIScrollView *)otherGestureRecognizer.view;
        CGPoint velocity = [gestureRecognizer.velocityInView:self.view];
        
        // 向下滑动 & 在顶部
        if (velocity.y < -1 && scrollView.contentOffset.y <= 0) {
            return YES;
        }
        // 向上滑动 & 在底部
        if (velocity.y > 1 && scrollView.contentOffset.y + scrollView.frame.size.height >= scrollView.contentSize.height) {
            return YES;
        }
    }
    return NO;
}

Android那边也对应做了onInterceptTouchEvent的拦截逻辑:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getActionMasked();
    float dy = ev.getY() - lastY;

    if (action == MotionEvent.ACTION_DOWN) {
        lastY = ev.getY();
        return false; // 不拦截初始按下
    }

    if (action == MotionEvent.ACTION_MOVE) {
        RecyclerView recyclerView = getTargetRecyclerView();
        if (recyclerView != null) {
            boolean canScrollVertically = recyclerView.canScrollVertically((int) Math.signum(dy));
            if (!canScrollVertically) {
                // 子控件无法继续滚动,交还给父布局处理
                return Math.abs(dy) > touchSlop;
            }
        }
        // 子控件还能滚,自己处理
        return false;
    }
    return super.onInterceptTouchEvent(ev);
}

这里注意我踩过好几次坑:dy的方向判断反了会导致上下颠倒;还有touchSlop没考虑设备差异,某些低端机灵敏度太高,一碰就触发切换Tab。后来加上了动态获取系统阈值才稳住。

通信这块也没少折腾

RN和原生之间传消息本来用的是RCTEventEmitter发事件,结果高频滚动下经常丢帧。连续滑动十几个Tab的时候,JS端收到的index滞后明显。换成直接回调(Callback)也不行,异步机制扛不住高频率更新。

最后上了原生模块的方法暴露,通过一个同步方法直接读取当前index:

const { TabContainerManager } = NativeModules;

// 高频读取当前tabIndex(仅限必要场景)
const currentIndex = TabContainerManager.getCurrentTabIndexSync();

对应的原生实现是返回实例变量,几乎是零延迟:

-c
RCT_EXPORT_METHOD(getCurrentTabIndexSync)
{
    return @([self getCurrentIndex]);
}

虽然官方不推荐频繁调用同步方法,但在这种性能敏感场景下,确实比事件队列靠谱多了。当然只在关键路径用,比如页面曝光埋点这种需要精确index的地方。

最终的解决方案

总结下来,方案就是:

  • 外层Tab切换完全由原生控制,保证手势流畅
  • RN容器作为子页注入,每个Tab独立管理生命周期
  • 滚动事件按边界条件分发,避免抢占
  • 高频数据通过同步接口读取,低频操作走事件通信

实际跑起来后,滑动体验确实接近原生水准。我们录了对比视频给产品看,他终于点头了。不过也不是100%完美,有个小问题到现在都没彻底解决:冷启动首次进入时,第二个Tab的内容会有大概200ms的白屏。原因是RN上下文还没初始化完,而原生已经把位置切过去了。

临时方案是在原生侧加了个loading占位,等JS回调ready后再显示真实内容。影响不大,用户感知不强,也就没再深挖了。

核心代码就这几行

最关键的其实是事件分发逻辑。我把这部分抽成了一个通用类,在两个项目里都复用了。下面是最简版本:

// NativeBridge.js
import { NativeEventEmitter, Platform } from 'react-native';

const eventEmitter = new NativeEventEmitter();

let currentTab = 0;

export const listenToTabChange = (callback) => {
  const subscription = eventEmitter.addListener('onTabIndexChanged', (e) => {
    currentTab = e.index;
    callback?.(e.index);
  });
  return subscription;
};

export const getCurrentTabSync = () => {
  return TabContainerManager.getCurrentTabIndexSync();
};

注册监听就行,不需要每次去拉数据:

useEffect(() => {
  const sub = listenToTabChange(setActiveIndex);
  return () => sub.remove();
}, []);

回顾与反思

现在回头看,其实一开始就不该执着于纯RN实现。有些体验上的细节,框架再怎么优化也追不上原生的手感。特别是涉及复杂手势嵌套的时候,越往上堆抽象层,问题越多。

这次最大的收获是对混合开发的理解更深了:不是非得二选一,而是哪块擅长干啥就让它上。原生搞交互,RN搞内容,分工明确反而更稳定。

当然也有遗憾。比如暗黑模式的同步做得有点糙,靠的是发事件通知两边切换,偶尔会有不同步的情况。理论上应该用共享存储+观察者模式重构,但排期紧就没动。留了个tech debt,下个版本再说吧。

还有就是文档没及时更新,后来新来的同事接入第三个Tab的时候走了不少弯路。血的教训:功能上线前,必须补完集成说明。

以上是我的项目经验,希望对你有帮助

这个方案不是最优解,也不是通用模板,但它解决了我们当时的燃眉之急。如果你也在做类似的混合容器,希望能少踩点我踩过的坑。

有更优的实现方式欢迎评论区交流,比如有没有人试过用TurboModules重做这套通信?我也挺想试试的。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论