Expo框架(一)- React Native使用集成原生PullRefresh功能

0 阅读9分钟

引言

ReactNative的很多第三方模块都不怎么维护了,现在质量有保障的是Expo官方提供的一系列库,比如expo-image可以替换很久没维护的react-native-fast-image,所以感觉RN开发的趋势就是Expo框架了。

我以前还挺担心Expo框架的一些第三方原生模块的集成和打包,其实当捋清楚它的功能和文档以后 还挺好用的,不用担心打包和第三方库的问题

集成第三方原生库(比如支付宝、微信、友盟、极光推送)等,打包可以prebuild出ios和安卓目录,然后用XcodeAndroid打包即可。

第三方库的集成原生代码可以使用AI生成。

1、生成Expo项目

查看npx create-expo-app@latest --help命令,初始化Expo项目如下,选一个就好了:

# 使用npm安装
npx create-expo-app 项目名

# 使用yarn安装
yarn create expo-app 项目名

# 使用pnpm安装
pnpm create expo-app 项目名

# 使用bun安装
bun create expo-app 项目名

初始化好以后,可以删除不要的web文件,比如截图所示:

image.png

2、集成 PullRefresh 功能

这里演示 Ios使用MJRefresh库,Android使用SmartRefreshLayout库。

首先创建一个Expo的原生模块,比如pull-refresh,跟着下面命令去创建就好。

npx create-expo-module@latest --local

2.1 ios集成 MJRefresh

修改modules/pull-refresh/ios/PullRefresh.podspec文件,加入MJRefresh

image.png

接着expo prebuild出安卓和ios目录

# prebuild出安卓和ios目录
npx expo prebuild --clean

编写ios原生代码,AI开发modules/pull-refresh/ios/PullRefreshModule.swift

import ExpoModulesCore
import MJRefresh

public class PullRefreshModule: Module {
  public func definition() -> ModuleDefinition {
    Name("PullRefresh")

    // 定义视图组件
    View(PullRefreshView.self) {
      // 事件定义
      Events("onRefresh", "onLoadMore")
      
      // Props定义 - 功能控制
      Prop("refreshEnabled") { (view: PullRefreshView, enabled: Bool) in
        view.setRefreshEnabled(enabled)
      }
      
      Prop("loadMoreEnabled") { (view: PullRefreshView, enabled: Bool) in
        view.setLoadMoreEnabled(enabled)
      }
      
      // Props定义 - 文字设置
      Prop("refreshingText") { (view: PullRefreshView, text: String) in
        view.setRefreshingText(text)
      }
      
      Prop("loadMoreText") { (view: PullRefreshView, text: String) in
        view.setLoadMoreText(text)
      }
      
      // Props定义 - 状态控制
      Prop("refreshingKey") { (view: PullRefreshView, key: String) in
        view.setRefreshingKey(key)
      }
      
      Prop("loadingKey") { (view: PullRefreshView, key: String) in
        view.setLoadingKey(key)
      }
      
      Prop("noMoreData") { (view: PullRefreshView, flag: Bool) in
        view.setNoMoreDataFlag(flag)
      }
      
      // 注意:不需要暴露 endRefresh 等方法给 JS
      // 已通过 Props 的 key 机制(refreshingKey、loadingKey)来控制刷新状态
      // 这种方式更符合 React 的声明式编程理念,且避免了复杂的方法调用
    }
  }
}

编写ios原生代码,AI开发modules/pull-refresh/ios/PullRefreshView.swift

import ExpoModulesCore
import UIKit
import MJRefresh

// PullRefresh视图组件,集成MJRefresh功能
class PullRefreshView: ExpoView {
  // 事件分发器
  let onRefresh = EventDispatcher()
  let onLoadMore = EventDispatcher()
  
  // 刷新状态标记
  private var isRefreshing = false
  private var isLoadingMore = false
  
  // 用于追踪状态变化的key
  private var currentRefreshingKey: String = ""
  private var currentLoadingKey: String = ""
  private var currentNoMoreData: Bool = false
  
  // 存储配置,等待找到ScrollView后应用
  private var refreshEnabled = true
  private var loadMoreEnabled = true
  private var refreshingText = "正在刷新..."
  private var loadMoreText = "加载更多..."
  private var idlePullingText = "下拉刷新"
  private var releasePullingText = "释放立即刷新"
  private var idleLoadingText = "点击或上拉加载更多"
  private var releaseLoadingText = "释放立即加载"
  private var noMoreDataText = "没有更多数据了"
  
  // 找到的ScrollView引用
  private weak var targetScrollView: UIScrollView?
  
  // 是否已经设置过刷新控件
  private var hasSetupRefreshControls = false
  
  // 同步队列,保证线程安全
  private let syncQueue = DispatchQueue(label: "com.pullrefresh.sync")
  
  required init(appContext: AppContext? = nil) {
    super.init(appContext: appContext)
    // 不创建自己的ScrollView,等待子视图添加
  }
  
  // 递归查找第一个UIScrollView(优化:添加深度限制)
  private func findScrollView(in view: UIView, depth: Int = 0, maxDepth: Int = 10) -> UIScrollView? {
    // 防止过深的递归
    guard depth < maxDepth else { return nil }
    
    // 优先检查当前视图
    if let scrollView = view as? UIScrollView {
      return scrollView
    }
    
    // 广度优先搜索,先检查直接子视图
    for subview in view.subviews {
      if let scrollView = subview as? UIScrollView {
        return scrollView
      }
    }
    
    // 如果直接子视图中没有,再递归搜索
    for subview in view.subviews {
      if let scrollView = findScrollView(in: subview, depth: depth + 1, maxDepth: maxDepth) {
        return scrollView
      }
    }
    
    return nil
  }
  
  // 在找到的ScrollView上设置MJRefresh
  private func setupRefreshControls(on scrollView: UIScrollView) {
    // 防止重复设置
    guard !hasSetupRefreshControls else {
      print("⚠️ PullRefresh: 刷新控件已设置,跳过重复设置")
      return
    }
    
    // 移除可能存在的旧控件
    scrollView.mj_header = nil
    scrollView.mj_footer = nil
    
    // 设置下拉刷新
    let header = MJRefreshNormalHeader { [weak self] in
      guard let self = self else { return }
      DispatchQueue.main.async {
        self.syncQueue.async {
          self.isRefreshing = true
        }
        self.onRefresh([:])
      }
    }
    header.lastUpdatedTimeLabel?.isHidden = true
    header.stateLabel?.textColor = .gray
    header.stateLabel?.font = .systemFont(ofSize: 14)
    
    // 设置不同状态的文字(使用配置的文字)
    header.setTitle(idlePullingText, for: .idle)           // 默认状态
    header.setTitle(releasePullingText, for: .pulling)     // 下拉中(超过触发距离)
    header.setTitle(refreshingText, for: .refreshing)      // 正在刷新
    header.setTitle("", for: .willRefresh)                 // 即将刷新
    header.setTitle("", for: .noMoreData)                  // 没有更多数据
    
    header.isHidden = !refreshEnabled
    scrollView.mj_header = header
    
    // 设置上拉加载
    let footer = MJRefreshAutoNormalFooter { [weak self] in
      guard let self = self else { return }
      DispatchQueue.main.async {
        self.syncQueue.async {
          self.isLoadingMore = true
        }
        self.onLoadMore([:])
      }
    }
    footer.stateLabel?.textColor = .gray
    footer.stateLabel?.font = .systemFont(ofSize: 14)
    
    // 设置不同状态的文字(使用配置的文字)
    footer.setTitle(idleLoadingText, for: .idle)          // 默认状态
    footer.setTitle(releaseLoadingText, for: .pulling)    // 上拉中
    footer.setTitle(loadMoreText, for: .refreshing)       // 正在加载
    footer.setTitle("", for: .willRefresh)                // 即将加载
    footer.setTitle(noMoreDataText, for: .noMoreData)     // 没有更多数据
    
    footer.isHidden = !loadMoreEnabled
    scrollView.mj_footer = footer
    
    hasSetupRefreshControls = true
    print("✅ PullRefresh: 刷新控件设置成功")
  }
  
  // 结束刷新(线程安全)
  func endRefresh() {
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.syncQueue.async {
        guard self.isRefreshing else { return }
        self.isRefreshing = false
      }
      self.targetScrollView?.mj_header?.endRefreshing()
    }
  }
  
  // 结束加载更多(线程安全)
  func endLoadMore() {
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.syncQueue.async {
        guard self.isLoadingMore else { return }
        self.isLoadingMore = false
      }
      self.targetScrollView?.mj_footer?.endRefreshing()
    }
  }
  
  // 没有更多数据(线程安全)
  func noMoreData() {
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.syncQueue.async {
        self.isLoadingMore = false
      }
      self.targetScrollView?.mj_footer?.endRefreshingWithNoMoreData()
    }
  }
  
  // 重置没有更多数据状态(线程安全)
  func resetNoMoreData() {
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      self.targetScrollView?.mj_footer?.resetNoMoreData()
    }
  }
  
  // 启用/禁用下拉刷新
  func setRefreshEnabled(_ enabled: Bool) {
    refreshEnabled = enabled
    DispatchQueue.main.async { [weak self] in
      self?.targetScrollView?.mj_header?.isHidden = !enabled
    }
  }
  
  // 启用/禁用上拉加载
  func setLoadMoreEnabled(_ enabled: Bool) {
    loadMoreEnabled = enabled
    DispatchQueue.main.async { [weak self] in
      self?.targetScrollView?.mj_footer?.isHidden = !enabled
    }
  }
  
  // 设置刷新文字
  func setRefreshingText(_ text: String) {
    refreshingText = text
    DispatchQueue.main.async { [weak self] in
      if let header = self?.targetScrollView?.mj_header as? MJRefreshNormalHeader {
        // 只更新正在刷新状态的文字
        header.setTitle(text, for: .refreshing)
      }
    }
  }
  
  // 设置加载更多文字
  func setLoadMoreText(_ text: String) {
    loadMoreText = text
    DispatchQueue.main.async { [weak self] in
      if let footer = self?.targetScrollView?.mj_footer as? MJRefreshAutoNormalFooter {
        // 只更新正在加载状态的文字
        footer.setTitle(text, for: .refreshing)
      }
    }
  }
  
  // 通过key控制刷新状态
  func setRefreshingKey(_ key: String) {
    if key != currentRefreshingKey && !key.isEmpty {
      currentRefreshingKey = key
      endRefresh()
    }
  }
  
  // 通过key控制加载状态
  func setLoadingKey(_ key: String) {
    if key != currentLoadingKey && !key.isEmpty {
      currentLoadingKey = key
      endLoadMore()
    }
  }
  
  // 设置没有更多数据状态
  func setNoMoreDataFlag(_ flag: Bool) {
    if flag != currentNoMoreData {
      currentNoMoreData = flag
      if flag {
        noMoreData()
      } else {
        resetNoMoreData()
      }
    }
  }
  
  override func layoutSubviews() {
    super.layoutSubviews()
    
    // 优化:只在需要时调整子视图大小
    if bounds.size != .zero {
      for subview in subviews where subview.frame.size != bounds.size {
        subview.frame = bounds
      }
    }
  }
  
  override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
    super.insertReactSubview(subview, at: atIndex)
    addSubview(subview)
    
    // 设置子视图frame
    subview.frame = bounds
    subview.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    
    // 优化:使用 DispatchQueue.main.async 而非延迟,更可靠
    DispatchQueue.main.async { [weak self] in
      self?.trySetupScrollView()
    }
  }
  
  override func removeReactSubview(_ subview: UIView!) {
    super.removeReactSubview(subview)
    
    // 修复:检查 targetScrollView 是否是被移除视图的子孙
    if let scrollView = targetScrollView {
      // 递归检查 scrollView 是否在 subview 的子树中
      if isView(scrollView, descendantOf: subview) {
        targetScrollView = nil
        hasSetupRefreshControls = false
        print("⚠️ PullRefresh: ScrollView 已被移除,清除引用")
      }
    }
    
    subview.removeFromSuperview()
  }
  
  // 辅助方法:检查一个视图是否是另一个视图的子孙
  private func isView(_ view: UIView, descendantOf parentView: UIView) -> Bool {
    var currentView: UIView? = view
    while let current = currentView {
      if current == parentView {
        return true
      }
      currentView = current.superview
    }
    return false
  }
  
  // 尝试查找并设置ScrollView(优化版本)
  private func trySetupScrollView() {
    // 如果已经找到并设置过了,检查是否还有效
    if let existingScrollView = targetScrollView {
      // 检查 ScrollView 是否还在视图树中
      if existingScrollView.superview == nil {
        print("⚠️ PullRefresh: 检测到 ScrollView 已从视图树移除")
        targetScrollView = nil
        hasSetupRefreshControls = false
      } else {
        // ScrollView 仍然有效,不需要重新设置
        return
      }
    }
    
    // 在子视图中查找ScrollView
    for subview in subviews {
      if let scrollView = findScrollView(in: subview) {
        targetScrollView = scrollView
        setupRefreshControls(on: scrollView)
        print("✅ PullRefresh: 找到 ScrollView 类型: \(type(of: scrollView))")
        return
      }
    }
    
    // 如果还是没找到,延迟重试一次(React Native 视图树可能还在构建中)
    if targetScrollView == nil {
      DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
        guard let self = self, self.targetScrollView == nil else { return }
        
        for subview in self.subviews {
          if let scrollView = self.findScrollView(in: subview) {
            self.targetScrollView = scrollView
            self.setupRefreshControls(on: scrollView)
            print("✅ PullRefresh: 延迟查找成功,找到 ScrollView")
            return
          }
        }
        
        print("⚠️ PullRefresh: 未找到 ScrollView,请确保子组件包含可滚动视图(ScrollView/FlatList等)")
      }
    }
  }
  
  // 当视图添加到窗口时,再次尝试查找ScrollView
  override func didMoveToWindow() {
    super.didMoveToWindow()
    if window != nil {
      trySetupScrollView()
    }
  }
  
  // 清理资源
  deinit {
    // 移除 MJRefresh 控件
    targetScrollView?.mj_header = nil
    targetScrollView?.mj_footer = nil
    print("🔄 PullRefresh: 视图已销毁,清理资源")
  }
}

编写src代码,AI开发modules/pull-refresh/src/PullRefresh.types.ts

import type { ReactNode } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';

// 下拉刷新事件回调
export type OnRefreshEventPayload = {
  // 可以添加额外的事件数据
};

// 上拉加载事件回调
export type OnLoadMoreEventPayload = {
  // 可以添加额外的事件数据
};

// PullRefresh视图组件的Props
export type PullRefreshViewProps = {
  // 子组件
  children?: ReactNode;
  
  // 样式
  style?: StyleProp<ViewStyle>;
  
  // 是否启用下拉刷新,默认true
  refreshEnabled?: boolean;
  
  // 是否启用上拉加载,默认true
  loadMoreEnabled?: boolean;
  
  // 下拉刷新的文字提示
  refreshingText?: string;
  
  // 上拉加载的文字提示
  loadMoreText?: string;
  
  // 下拉刷新回调
  onRefresh?: () => void;
  
  // 上拉加载回调
  onLoadMore?: () => void;
  
  // 控制刷新状态的ID,每次改变会触发刷新结束
  refreshingKey?: string | number;
  
  // 控制加载状态的ID,每次改变会触发加载结束
  loadingKey?: string | number;
  
  // 是否显示"没有更多数据"
  noMoreData?: boolean;
};

编写src代码,AI开发modules/pull-refresh/src/PullRefreshComponent.tsx

import * as React from 'react';
import { PullRefreshViewProps } from './PullRefresh.types';
import PullRefreshView from './PullRefreshView';

// Ref控制方法接口
export interface PullRefreshRef {
  // 结束下拉刷新
  endRefresh: () => void;
  // 结束上拉加载
  endLoadMore: () => void;
  // 设置没有更多数据
  noMoreData: () => void;
  // 重置没有更多数据状态
  resetNoMoreData: () => void;
}

/**
 * 带Ref控制的PullRefresh组件
 * 
 * 使用forwardRef支持通过ref调用控制方法
 * 
 * @example
 * ```tsx
 * const pullRefreshRef = useRef<PullRefreshRef>(null);
 * 
 * const handleRefresh = async () => {
 *   await fetchData();
 *   pullRefreshRef.current?.endRefresh();
 * };
 * 
 * <PullRefresh
 *   ref={pullRefreshRef}
 *   onRefresh={handleRefresh}
 * >
 *   <YourContent />
 * </PullRefresh>
 * ```
 */
const PullRefresh = React.forwardRef<PullRefreshRef, PullRefreshViewProps>(
  (props, ref) => {
    // 使用状态来触发刷新结束
    const [refreshingKey, setRefreshingKey] = React.useState('');
    const [loadingKey, setLoadingKey] = React.useState('');
    const [hasNoMoreData, setHasNoMoreData] = React.useState(false);

    // 暴露控制方法给外部
    React.useImperativeHandle(ref, () => ({
      endRefresh: () => {
        // 改变key来触发刷新结束
        setRefreshingKey(Date.now().toString());
      },
      endLoadMore: () => {
        // 改变key来触发加载结束
        setLoadingKey(Date.now().toString());
      },
      noMoreData: () => {
        setHasNoMoreData(true);
        setLoadingKey(Date.now().toString());
      },
      resetNoMoreData: () => {
        setHasNoMoreData(false);
      },
    }));

    return (
      <PullRefreshView 
        {...props}
        refreshingKey={refreshingKey}
        loadingKey={loadingKey}
        noMoreData={hasNoMoreData}
      />
    );
  }
);

PullRefresh.displayName = 'PullRefresh';

export default PullRefresh;

编写src代码,AI开发modules/pull-refresh/src/PullRefreshModule.ts

import { NativeModule, requireNativeModule } from 'expo';

// 定义原生模块接口
declare class PullRefreshModule extends NativeModule {
  // 这里可以添加模块级别的方法(如果需要)
}

// 加载原生模块
const nativeModule = requireNativeModule<PullRefreshModule>('PullRefresh');

export default nativeModule;

编写src代码,AI开发modules/pull-refresh/src/PullRefreshView.tsx

import { requireNativeView } from 'expo';
import * as React from 'react';
import { View } from 'react-native';

import { PullRefreshViewProps } from './PullRefresh.types';

// 导入原生视图 - 使用any类型避免类型问题
const NativeView: any = requireNativeView('PullRefresh');

/**
 * PullRefresh 下拉刷新组件
 * 
 * 跨平台原生下拉刷新和上拉加载体验:
 * - iOS: 使用 MJRefresh 库
 * - Android: 使用 SmartRefreshLayout 库
 * 
 * @example
 * ```tsx
 * <PullRefreshView
 *   refreshEnabled={true}
 *   loadMoreEnabled={true}
 *   onRefresh={handleRefresh}
 *   onLoadMore={handleLoadMore}
 * >
 *   <FlatList data={data} ... />
 * </PullRefreshView>
 * ```
 */
const PullRefreshView = React.forwardRef<View, PullRefreshViewProps>(
  (props, ref) => {
    const {
      children,
      style,
      refreshEnabled = true,
      loadMoreEnabled = true,
      refreshingText = '正在刷新...',
      loadMoreText = '加载更多...',
      onRefresh,
      onLoadMore,
      ...otherProps
    } = props;

    // 处理下拉刷新事件
    const handleRefresh = React.useCallback(() => {
      if (onRefresh) {
        onRefresh();
      }
    }, [onRefresh]);

    // 处理上拉加载事件
    const handleLoadMore = React.useCallback(() => {
      if (onLoadMore) {
        onLoadMore();
      }
    }, [onLoadMore]);

    return (
      <NativeView
        ref={ref}
        style={style}
        refreshEnabled={refreshEnabled}
        loadMoreEnabled={loadMoreEnabled}
        refreshingText={refreshingText}
        loadMoreText={loadMoreText}
        onRefresh={handleRefresh}
        onLoadMore={handleLoadMore}
        {...otherProps}
      >
        {children}
      </NativeView>
    );
  }
);

PullRefreshView.displayName = 'PullRefreshView';

export default PullRefreshView;

编写src代码,AI开发modules/pull-refresh/index.ts

// 导出主要的PullRefresh组件(带ref支持)
export { default, PullRefreshRef } from './src/PullRefreshComponent';

// 导出基础视图组件
export { default as PullRefreshView } from './src/PullRefreshView';

// 导出原生模块(如果需要直接访问)
export { default as PullRefreshModule } from './src/PullRefreshModule';

// 导出类型定义
export * from './src/PullRefresh.types';

2.2 ios演示

2.3 android集成 SmartRefreshLayout

后面再更新...