Chapter 10

实战:社交应用

将前九章所学融合为一个完整的 Feed 流社交 App:无限滚动、相机上传、推送通知、深度链接、共享元素动画。

功能需求与技术选型

我们要构建一个微型社交应用,核心功能包括:用户登录(JWT + 刷新 Token)、发帖(文字 + 图片,相机或相册选取)、Feed 流(无限滚动)、点赞(乐观更新)、推送通知(新粉丝/新点赞)、点击通知跳转到对应帖子(深度链接)、图片全屏预览(共享元素动画)。

核心名词解释

Feed 流
按时间或算法排序的内容列表,用户向下滚动可持续加载新内容。是社交应用最核心的 UI 模式,实现要点是无限滚动(Infinite Scroll)和虚拟化列表。
无限滚动(Infinite Scroll)
FlatList 的 onEndReached 事件 + React Query 的 useInfiniteQuery,当用户滚动到列表底部时自动请求下一页数据并追加到列表末尾。
乐观更新(Optimistic Update)
用户操作(如点赞)后立即更新 UI,同时发送网络请求。若请求失败则回滚 UI 并提示错误。让用户感觉操作即时响应,是移动端 UX 的重要技巧。
深度链接(Deep Link)
用户点击推送通知或分享链接后,直接打开应用并跳转到对应页面(如帖子详情)。需要在导航配置中定义 URL scheme 和路由映射。
共享元素动画
从列表缩略图点击进入详情页时,图片流畅地"扩展"到详情页的大图位置,给用户连续感。通过 Reanimated Shared Transition API 实现。
Push Notification Token
设备的推送通知唯一标识符,分为 APNs Device Token(iOS)和 FCM Registration Token(Android)。Expo 统一抽象为 Expo Push Token,后端调用 Expo 的 Push API 即可向两个平台发送。

完整应用架构

社交 App 整体架构 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ┌─────────────────────────────────────────────────────────┐ │ 客户端(RN + Expo) │ │ │ │ Expo Router(文件路由) │ │ ├── app/(auth)/login.tsx 未登录流程 │ │ ├── app/(tabs)/ 主 Tab 导航 │ │ │ ├── index.tsx Feed 首页 │ │ │ ├── camera.tsx 拍照/发帖 │ │ │ └── profile.tsx 个人主页 │ │ └── app/post/[id].tsx 帖子详情(深度链接入口) │ │ │ │ 状态层: │ │ • Zustand:authUser, theme │ │ • React Query:posts, comments, users(服务端状态) │ │ • MMKV:持久化 token、用户偏好 │ │ │ │ 原生能力: │ │ • expo-image-picker:选图/拍照 │ │ • expo-notifications:推送通知 │ │ • expo-image:图片缓存展示 │ └──────────────────────┬──────────────────────────────────┘ │ HTTPS + WebSocket ▼ ┌─────────────────────────────────────────────────────────┐ │ 后端服务 │ │ │ │ REST API(Node.js / Go) │ │ ├── POST /auth/login → JWT + RefreshToken │ │ ├── GET /feed?cursor=xxx → 帖子分页(游标分页) │ │ ├── POST /posts → 创建帖子 │ │ ├── POST /posts/:id/like → 点赞 │ │ └── POST /upload/presign → S3 预签名 URL │ │ │ │ 推送服务: │ │ Server → Expo Push API → APNs / FCM → 用户手机 │ └─────────────────────────────────────────────────────────┘

Feed 无限滚动实现

无限滚动是 Feed 流应用的核心技术点。使用游标分页(Cursor-based Pagination)而非页码分页,可以避免数据插入/删除导致的重复或跳过问题。

// hooks/useFeed.ts — 无限滚动 Hook
import { useInfiniteQuery } from '@tanstack/react-query';
import { api } from '../lib/api';

interface FeedResponse {
  posts: Post[];
  nextCursor: string | null;
  hasMore: boolean;
}

export function useFeed() {
  return useInfiniteQuery<FeedResponse>({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) =>
      api.get(`/feed?cursor=${pageParam ?? ''}&limit=20`),
    initialPageParam: undefined,
    // getNextPageParam:从当前页数据中提取下一页的游标
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.nextCursor : undefined,
    staleTime: 2 * 60 * 1000,
  });
}

// screens/FeedScreen.tsx
import { FlatList, ActivityIndicator } from 'react-native';
import { useFeed } from '../hooks/useFeed';

export function FeedScreen() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    refetch,
  } = useFeed();

  // 将多页数据展平为单一数组
  const posts = data?.pages.flatMap(page => page.posts) ?? [];

  return (
    <FlatList
      data={posts}
      renderItem={({ item }) => <PostCard post={item} />}
      keyExtractor={post => post.id}
      // 滚动到距离底部 200px 时触发加载下一页
      onEndReached={() => hasNextPage && fetchNextPage()}
      onEndReachedThreshold={0.3}
      // 列表底部的加载指示器
      ListFooterComponent={
        isFetchingNextPage
          ? <ActivityIndicator color="#38bdf8" style={{ padding: 20 }} />
          : null
      }
      // 下拉刷新
      onRefresh={refetch}
      refreshing={isLoading}
    />
  );
}

相机上传完整流程

// screens/CreatePostScreen.tsx — 发帖功能
import * as ImagePicker from 'expo-image-picker';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Image } from 'expo-image';
import { useState } from 'react';

export function CreatePostScreen() {
  const [text, setText] = useState('');
  const [imageUri, setImageUri] = useState<string | null>(null);
  const queryClient = useQueryClient();

  const { mutate: createPost, isPending } = useMutation({
    mutationFn: async () => {
      let imageUrl: string | undefined;

      if (imageUri) {
        // 1. 获取预签名 URL
        const { uploadUrl, publicUrl } = await api.post('/upload/presign', {
          contentType: 'image/jpeg',
        });
        // 2. 上传图片到 S3
        const blob = await (await fetch(imageUri)).blob();
        await fetch(uploadUrl, { method: 'PUT', body: blob });
        imageUrl = publicUrl;
      }

      // 3. 创建帖子
      return api.post('/posts', { text, imageUrl });
    },
    onSuccess: () => {
      // 4. 刷新 Feed(让新帖子出现在顶部)
      queryClient.invalidateQueries({ queryKey: ['feed'] });
      setText('');
      setImageUri(null);
      router.back(); // 发布成功后返回 Feed
    },
  });

  const pickImage = async () => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
      quality: 0.8,
      allowsEditing: true,
      aspect: [4, 3],
    });
    if (!result.canceled) setImageUri(result.assets[0].uri);
  };

  return (
    <View style={styles.container}>
      <TextInput
        value={text}
        onChangeText={setText}
        placeholder="分享你的想法..."
        multiline
        style={styles.input}
      />
      {imageUri && (
        <Image source={{ uri: imageUri }} style={styles.preview} />
      )}
      <View style={styles.actions}>
        <Pressable onPress={pickImage}>
          <Text>📷 选择图片</Text>
        </Pressable>
        <Pressable
          onPress={() => createPost()}
          disabled={isPending || !text.trim()}
          style={[styles.submit, isPending && styles.disabled]}
        >
          <Text>{isPending ? '发布中...' : '发布'}</Text>
        </Pressable>
      </View>
    </View>
  );
}

共享元素动画:图片全屏预览

import Animated, {
  useSharedValue, withSpring,
  useAnimatedStyle
} from 'react-native-reanimated';
import { Modal, Pressable } from 'react-native';

export function PostImage({ uri, width, height }: PostImageProps) {
  const [fullscreen, setFullscreen] = useState(false);
  const scale = useSharedValue(1);
  const opacity = useSharedValue(1);

  const open = () => {
    setFullscreen(true);
    scale.value = withSpring(1, { damping: 20 });
    opacity.value = withSpring(1);
  };

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
    opacity: opacity.value,
  }));

  return (
    <>
      <Pressable onPress={open}>
        <Animated.Image
          source={{ uri }}
          style={[{ width, height }, animatedStyle]}
          // Reanimated Shared Transition 标签(需 RN 0.73+)
          sharedTransitionTag={`image-${uri}`}
        />
      </Pressable>

      <Modal visible={fullscreen} transparent animationType="none">
        <Pressable
          style={styles.overlay}
          onPress={() => setFullscreen(false)}
        >
          <Animated.Image
            source={{ uri }}
            style={styles.fullscreenImage}
            sharedTransitionTag={`image-${uri}`}  // 相同 tag,触发共享元素动画
            resizeMode="contain"
          />
        </Pressable>
      </Modal>
    </>
  );
}
乐观更新是体验关键 点赞这种高频操作,务必实现乐观更新——用户点击后立即更新 UI(点赞数 +1、按钮变为已点赞状态),无需等待网络响应。如果服务端返回失败,再回滚并显示提示。这比"先请求后更新"的体验流畅感提升非常明显,尤其在弱网环境下。
项目完成了! 你已经学完了 React Native 跨平台开发的核心体系:架构原理 → 核心组件 → 导航 → 状态管理 → 网络 → 原生能力 → 动画 → 性能优化 → 测试发布 → 实战项目。下一步建议:动手搭建这个社交 App 的完整版本,在真机上调试,体验 EAS Build 发布流程。