MoreRSS

site iconInnei | 拾一修改

数字游民,NodeJS全栈开发,前支付宝、小红书。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Innei | 拾一的 RSS 预览

工作周年与AI焦虑:成长与挑战并存

2025-05-23 23:06:17

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/192

Folo 与我的一年

转眼间,这份工作又快到一年了。说起来,我毕业以来,经历了两次换工作,第一次是因为工作内容和压力的问题,第二次是因为裁员。不幸的是,这两次实际都没有满一年。

又是这里节骨眼上,恰好是一年,又开始有点焦虑了。焦虑的是,做的事是否达到预期,是否有成长,产品时候有前途等等。一年的时间说长不长说短也不短。

回顾过去的一年,我自认为还是能达到预期的吧。从我刚入职,从一个毛坯的产品开始做,重新设计组件 UI,重构底层,赋予一些有意思的交互设计以及功能,到 Web 端开放公测。不过只是三个月的时间。又是一个月完成了 Web 对 Mobile 的响应式改造。现如今 Mobile App 也已经上线很久。而这些在一年的时间中全部完成了。对于一个只有 4 个开发成员的项目来说,感觉已经很不错了。

希望越来越好吧。

AI 焦虑

回看今年,各路神仙打架。AI 发展越来越快。该说是好事还是坏事呢。如今写代码变得越来越不动脑子了,只是无脑敲着 Tab,出了问题也不知道。非常害怕这样依赖 AI 之后连很简单的逻辑都不会写了。AI 在进步而我在退步。

前段日子,也是借助 AI 完成了大部分的代码。虽然 AI 现在对 Swift 还是不太熟悉。但是循序渐进的引导最终还是能达到一个预期的结果。现在非常流行 Vibe Coding,即便是完全不会编程,只需要一个好点子,以及良好的文字表达,就能引导 AI 一步步做出你想要的结果。慢慢的代码越来越不值钱,最值钱的是好的点子。像我这种只会切图的低级程序员真的那一天就突然被淘汰了。现在的 AI 可比我会写多了。这种焦虑感越来越强。

确实会有一种很矛盾的感觉,一方面对自己所能开发的领域、边界有了更多信心,可以开发前端、iOS 甚至是各种之前并不了解的技术栈;而另一方面,对于自己离开 AI 后独立写代码的信心在显著下降,连带着自己独立思考的能力。 -- 周报 #95 - All AI 与 No AI

看完了?说点什么呢

随手拍的烂片

2025-05-11 21:28:23

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/191

记录最近随手拍的烂片。

嘟悠悠农场

南浔古镇

中国动漫博物馆

旭辉广场

西南湖

姚家荡

看完了?说点什么呢

大学时光回忆录

2025-05-11 01:57:06

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/190

有时间整理了下 iCloud 相册,删除了许多没有留恋价值的图片。回顾老照片,感受到当时的感动和喜悦,现在看来也是一种珍贵的回忆。

这段回忆从大学开始,再之前的也找不到了。

2018 年,大一。

小米笔记本 + 黑果,踏上开发之路。

2019 年,大二。开始学习前端,刚开始,学的是 Vue2 + Epxress。

2019 年底,找到了一个 Remote 的实习机会。学习 React。然后在这条路上越走越远。

2020 年,经过了半年疫情和实习,在下半年返校季,凭自己的努力换上了 Macbook Pro,黑果转正。也买了人生第一台游戏机,Nintendo Switch。

2020 年的十月,原神上线了。而我在沉迷塞尔达传说旷野之息。

2020 年底,我开始写 Mix Space,一写就是 5 年。

我把这个小窝,布置的很好,这也是我最快乐的快乐的一段时间。

现在想起来前司给我过生日还是挺感动的。2021.4.1

我真的很早就有在写 Swift UI 了,虽然那时候和 Lakr 还没有和见过面。于 2021 年 4 月在学校图书馆。

2021 年劳动节,第一次来杭州,见到了 Lakr。准备前往蚂蚁实习的预备。

2021.7.7,第一次租房,在杭州,三墩,单间,2750。现在回看真是被割惨了。

2021.8 底,离开了,这两个月过得非常煎熬,一点都不快乐。

后面就是秋招了。

https://innei.in/notes/104

在后面我就毕业了。

大学时光匆匆。

看完了?说点什么呢

使用 React Native Screens 构建一个 Simple Navigation

2025-05-09 01:01:19

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/build-simple-navigation-with-react-native-screens

上回说到,我们已经大概了解了 React Native Screen 内部是如何工作的。这篇文章将综合前面的内容,实现一个简单的 React Native Navigation。

构想

一个 Navigation 最基础的就是要实现一个 navigate 方法。

navigate 方法需要实现前进后退的基本行为。

模拟在 iOS Native 中,我们可以使用 pushController 和 presentController 两个方法去实现前进的行为。

那么我们可以命名为:

  • pushControllerView 推入 Stack Navigation
  • presentControllerView 推入 Modal

然后是后退的行为,命名为:

  • back 回退 Stack Navigation
  • dismiss 关闭 Modal

然后我们需要集中式管理 push 和 present 的 navigation 的数据,然后在 UI 中呈现和反馈。

实现

Navigation 框架

顺着上面的构想,我们首先需要一个 Navigation 的类。

class Navigation {}

我们需要把 navigation 的数据保存在这个类中,所以我还需要定义数据的类型。

export interface Route {
  id: string

  Component?: NavigationControllerView<any>
  element?: React.ReactElement

  type: NavigationControllerViewType
  props?: unknown
  screenOptions?: NavigationControllerViewExtraProps
}

export type NavigationControllerViewExtraProps = {
  /**
   * Unique identifier for the view.
   */
  id?: string

  /**
   * Title for the view.
   */
  title?: string

  /**
   * Whether the view is transparent.
   */
  transparent?: boolean
} & Pick<
  ScreenProps,
  | 'sheetAllowedDetents'
  | 'sheetCornerRadius'
  | 'sheetExpandsWhenScrolledToEdge'
  | 'sheetElevation'
  | 'sheetGrabberVisible'
  | 'sheetInitialDetentIndex'
  | 'sheetLargestUndimmedDetentIndex'
>

export type NavigationControllerView<P = {}> = FC<P> &
  NavigationControllerViewExtraProps

上面我们定义 NavigationControllerView 的类型,和 Route 的类型。NavigationControllerView 用于定义 NavigationView 的组件类型,Route 用于在 Navigation 类中保存 navigation 的数据。

为了实现在 UI 中的响应式,我们使用 Jotai 去管理这个数据。

export type ChainNavigationContextType = {
  routesAtom: PrimitiveAtom<Route[]>
}

在 Navigation 类中初始化数据:

export class Navigation {
  private ctxValue: ChainNavigationContextType
  constructor(ctxValue: ChainNavigationContextType) {
    this.ctxValue = ctxValue
  }

  static readonly rootNavigation: Navigation = new Navigation({
    routesAtom: atom<Route[]>([]),
  })
}

Navigation 数据管理

上面已经定义了 Navigation 的类型,然后我们通过对数据的控制来实现 push/back 的操作。

class Navigation {
  private viewIdCounter = 0
  private __push(route: Route) {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const hasRoute = routes.some((r) => r.id === route.id)
    if (hasRoute && routes.at(-1)?.id === route.id) {
      console.warn(`Top of stack is already ${route.id}`)
      return
    } else if (hasRoute) {
      route.id = `${route.id}-${this.viewIdCounter++}`
    }
    jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])
  }

  private resolveScreenOptions<T>(
    view: NavigationControllerView<T>,
  ): Required<NavigationControllerViewExtraProps> {
    return {
      transparent: view.transparent ?? false,
      id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,
      title: view.title ?? '',
      // Form Sheet
      sheetAllowedDetents: view.sheetAllowedDetents ?? 'fitToContents',
      sheetCornerRadius: view.sheetCornerRadius ?? 16,
      sheetExpandsWhenScrolledToEdge:
        view.sheetExpandsWhenScrolledToEdge ?? true,
      sheetElevation: view.sheetElevation ?? 24,
      sheetGrabberVisible: view.sheetGrabberVisible ?? true,
      sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,
      sheetLargestUndimmedDetentIndex:
        view.sheetLargestUndimmedDetentIndex ?? 'medium',
    }
  }

  pushControllerView<T>(view: NavigationControllerView<T>, props?: T) {
    const screenOptions = this.resolveScreenOptions(view)
    this.__push({
      id: screenOptions.id,
      type: 'push',
      Component: view,
      props,
      screenOptions,
    })
  }

  presentControllerView<T>(
    view: NavigationControllerView<T>,
    props?: T,
    type: Exclude<NavigationControllerViewType, 'push'> = 'modal',
  ) {
    const screenOptions = this.resolveScreenOptions(view)
    this.__push({
      id: screenOptions.id,
      type,
      Component: view,
      props,
      screenOptions,
    })
  }
}

之后,back 的操作也非常简单。

class Navigation {
  private __pop() {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const lastRoute = routes.at(-1)
    if (!lastRoute) {
      return
    }
    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))
  }

  /**
   * Dismiss the current modal.
   */
  dismiss() {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const lastModalIndex = routes.findLastIndex((r) => r.type !== 'push')
    if (lastModalIndex === -1) {
      return
    }
    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
  }

  back() {
    return this.__pop()
  }
}

从上面的代码不难看出,其实我们只是通过对数据的操作实现 Navigation 的逻辑。而真正要在 UI 中呈现 Navigation 的效果还是需要通过 React Native Screens 来实现。

Navigation UI 框架

在上面的文章中,我们已经知道了我们只需要通过传入不同 React Children 到 React Native Screens 的 <ScreenStack /> 中就能实现原生的 navigate 的效果。

那我们现在只需要透过 Navigation 类中管理的数据,通过一些转换就能实现了。

首先我们在 React 中定义一个 Navigation 上下文对象,确保得到正确的 Navigation 实例(如有多个)。

export const NavigationInstanceContext = createContext<Navigation>(null!)

然后,编写一个 RootStackNavigation 组件。

import { SafeAreaProvider } from 'react-native-safe-area-context'
import type { ScreenStackHeaderConfigProps } from 'react-native-screens'
import { ScreenStack } from 'react-native-screens'

interface RootStackNavigationProps {
  children: React.ReactNode

  headerConfig?: ScreenStackHeaderConfigProps
}

export const RootStackNavigation = ({
  children,
  headerConfig,
}: RootStackNavigationProps) => {
  return (
    <SafeAreaProvider>
      <NavigationInstanceContext value={Navigation.rootNavigation}>
        <ScreenStack style={StyleSheet.absoluteFill}>
          <ScreenStackItem headerConfig={headerConfig} screenId="root">
            {children}
          </ScreenStackItem>
        </ScreenStack>
      </NavigationInstanceContext>
    </SafeAreaProvider>
  )
}

在 App 的入口文件中,我们使用 RootStackNavigation 组件包裹整个应用。

export default function App() {
  return (
    <RootStackNavigation>
       <HomeScreen>
    </RootStackNavigation>
  )
}

const HomeScreen = () => {
  return (
    <View>
      <Text>Home</Text>
    </View>
  )
}

RootStackNavigation 组件的 Children 为首屏,也是 Navigation 的根组件,不参与整体的 navigate 行为,即不能被 pop。

Navigation 数据在 UI 中呈现

接下来我们需要把这些数据转换到 React 元素传入到 React Native Screens 的 <ScreenStackItem /> 中。

const ScreenItemsMapper = () => {
  const chainCtxValue = use(ChainNavigationContext)
  const routes = useAtomValue(chainCtxValue.routesAtom)

  const routeGroups = useMemo(() => {
    const groups: Route[][] = []
    let currentGroup: Route[] = []

    routes.forEach((route, index) => {
      // Start a new group if this is the first route or if it's a modal (non-push)
      if (index === 0 || route.type !== 'push') {
        // Save the previous group if it's not empty
        if (currentGroup.length > 0) {
          groups.push(currentGroup)
        }
        // Start a new group with this route
        currentGroup = [route]
      } else {
        // Add to the current group if it's a push route
        currentGroup.push(route)
      }
    })

    // Add the last group if it's not empty
    if (currentGroup.length > 0) {
      groups.push(currentGroup)
    }

    return groups
  }, [routes])

  return (
    <GroupedNavigationRouteContext value={routeGroups}>
      {routeGroups.map((group) => {
        const isPushGroup = group.at(0)?.type === 'push'
        if (!isPushGroup) {
          return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />
        }
        return <MapScreenStackItems key={group.at(0)?.id} routes={group} />
      })}
    </GroupedNavigationRouteContext>
  )
}

const MapScreenStackItems: FC<{
  routes: Route[]
}> = memo(({ routes }) => {
  return routes.map((route) => {
    return (
      <ScreenStackItem
        stackPresentation={'push'}
        key={route.id}
        screenId={route.id}
        screenOptions={route.screenOptions}
      >
        <ResolveView
          comp={route.Component}
          element={route.element}
          props={route.props}
        />
      </ScreenStackItem>
    )
  })
})

const ModalScreenStackItems: FC<{
  routes: Route[]
}> = memo(({ routes }) => {
  const rootModalRoute = routes.at(0)
  const modalScreenOptionsCtxValue = useMemo<
    PrimitiveAtom<ScreenOptionsContextType>
  >(() => atom({}), [])

  const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)

  if (!rootModalRoute) {
    return null
  }
  const isFormSheet = rootModalRoute.type === 'formSheet'
  const isStackModal = !isFormSheet

  // Modal screens are always full screen on Android
  const isFullScreen =
    isAndroid ||
    (rootModalRoute.type !== 'modal' && rootModalRoute.type !== 'formSheet')

  if (isStackModal) {
    return (
      <ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>
        <WrappedScreenItem
          stackPresentation={rootModalRoute?.type}
          key={rootModalRoute.id}
          screenId={rootModalRoute.id}
          screenOptions={rootModalRoute.screenOptions}
          {...modalScreenOptions}
        >
          <ModalSafeAreaInsetsContext hasTopInset={isFullScreen}>
            <ScreenStack style={StyleSheet.absoluteFill}>
              <WrappedScreenItem
                screenId={rootModalRoute.id}
                screenOptions={rootModalRoute.screenOptions}
              >
                <ResolveView
                  comp={rootModalRoute.Component}
                  element={rootModalRoute.element}
                  props={rootModalRoute.props}
                />
              </WrappedScreenItem>
              {routes.slice(1).map((route) => {
                return (
                  <WrappedScreenItem
                    stackPresentation={'push'}
                    key={route.id}
                    screenId={route.id}
                    screenOptions={route.screenOptions}
                  >
                    <ResolveView
                      comp={route.Component}
                      element={route.element}
                      props={route.props}
                    />
                  </WrappedScreenItem>
                )
              })}
            </ScreenStack>
          </ModalSafeAreaInsetsContext>
        </WrappedScreenItem>
      </ModalScreenItemOptionsContext>
    )
  }

  return routes.map((route) => {
    return (
      <ModalScreenItemOptionsContext
        value={modalScreenOptionsCtxValue}
        key={route.id}
      >
        <ModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>
          <WrappedScreenItem
            screenId={route.id}
            stackPresentation={route.type}
            screenOptions={route.screenOptions}
          >
            <ResolveView
              comp={route.Component}
              element={route.element}
              props={route.props}
            />
          </WrappedScreenItem>
        </ModalSafeAreaInsetsContext>
      </ModalScreenItemOptionsContext>
    )
  })
})

const ResolveView: FC<{
  comp?: NavigationControllerView<any>
  element?: React.ReactElement
  props?: unknown
}> = ({ comp: Component, element, props }) => {
  if (Component && typeof Component === 'function') {
    return <Component {...(props as any)} />
  }
  if (element) {
    return element
  }
  throw new Error('No component or element provided')
}

const ModalSafeAreaInsetsContext: FC<{
  children: React.ReactNode
  hasTopInset?: boolean
}> = ({ children, hasTopInset = true }) => {
  const rootInsets = useSafeAreaInsets()
  const rootFrame = useSafeAreaFrame()

  return (
    <SafeAreaFrameContext value={rootFrame}>
      <SafeAreaInsetsContext
        value={useMemo(
          () => ({
            ...rootInsets,
            top: hasTopInset ? rootInsets.top : 0,
          }),
          [hasTopInset, rootInsets],
        )}
      >
        {children}
      </SafeAreaInsetsContext>
    </SafeAreaFrameContext>
  )
}

这里需要判断的逻辑可能会有点复杂,需要区分 Stack 和 Modal 的类型,在 ModalStack 中又需要区分 formSheet 等等。同时每个 Modal 中有需要再包裹一层 StackScreen 等等。

从简单来说,就是需要根据 Navigation 的数据,生成对应的 <ScreenStackItem />,然后传入到 <ScreenStack /> 中。

这里的详细的代码均可在下面的链接中查看:

https://github.com/RSSNext/Follow/blob/efc2e9713bcd54f82f9377de35ef5532008d6004/apps/mobile/src/lib/navigation/StackNavigation.tsx

然后我们还需要处理 native navigation 的状态同步,主要在 native 触发 pop 和 dismiss 的时机发送的事件。在前面的文章中讲过,可以通过 ScreenStackItemonDismissed 监听。

这里我们直接对 ScreenStackItem 再次封装。

export const WrappedScreenItem: FC<
  {
    screenId: string
    children: React.ReactNode
    stackPresentation?: StackPresentationTypes

    screenOptions?: NavigationControllerViewExtraProps
    style?: StyleProp<ViewStyle>
  } & ScreenOptionsContextType
> = memo(
  ({
    screenId,
    children,
    stackPresentation,

    screenOptions: screenOptionsProp,
    style,
    ...rest
  }) => {
    const navigation = useNavigation()

    const screenOptionsCtxValue = useMemo<
      PrimitiveAtom<ScreenOptionsContextType>
    >(() => atom({}), [])

    const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)

    // Priority: Ctx > Define on Component

    const mergedScreenOptions = useMemo(
      () => ({
        ...screenOptionsProp,
        ...resolveScreenOptions(screenOptionsFromCtx),
      }),
      [screenOptionsFromCtx, screenOptionsProp],
    )

    const handleDismiss = useCallback(
      (
        e: NativeSyntheticEvent<{
          dismissCount: number
        }>,
      ) => {
        if (e.nativeEvent.dismissCount > 0) {
          for (let i = 0; i < e.nativeEvent.dismissCount; i++) {
            navigation.__internal_dismiss(screenId)
          }
        }
      },
      [navigation, screenId],
    )

    const ref = useRef<View>(null)

    return (
      <ScreenItemContext value={ctxValue}>
        <ScreenOptionsContext value={screenOptionsCtxValue}>
          <ScreenStackItem
            key={screenId}
            screenId={screenId}
            ref={ref}
            stackPresentation={stackPresentation}
            style={[StyleSheet.absoluteFill, style]}
            {...rest}
            {...mergedScreenOptions}
            onDismissed={handleDismiss}
            onNativeDismissCancelled={handleDismiss}
          >
            {children}
          </ScreenStackItem>
        </ScreenOptionsContext>
      </ScreenItemContext>
    )
  },
)

定义 NavigationControllerView

export const PlayerScreen: NavigationControllerView = () => {
  return <SheetScreen onClose={() => navigation.dismiss()}></SheetScreen>
}

PlayerScreen.transparent = true

使用 Navigation

那么现在我们就可以在 React 中使用 Navigation 了。

const navigation = useNavigation()

navigation.pushControllerView(PlayerScreen)

那么,一个简单的 Navigation 就完成了。

当然如果你有兴趣的话,也可以查看 Folo 这部分的完整实现,包括如何和 Bottom Tab 结合和页面 ScrollView 的联动。

https://github.com/RSSNext/Follow/blob/6694a346a0bd9f2cea19c71e87484acc56ed3705/apps/mobile/src/lib/navigation

看完了?说点什么呢

平淡中的小确幸

2025-04-24 00:40:16

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/189

又是一个月过去了。

今年开始基本每个月只会写一篇手记了。生活中发生的事很少,表达欲也变淡了许多。

相聚片刻

自上个月以来,这个月没有去太多地方。借群友的路过,在我家住了一晚,也为了过了一次生日,一起吃了饭,那天我也没有太孤独,很是感动。第二天跟着一起去了上海,在上海和朋友们吃了一顿日料自助,解锁了新的吃法,因为不吃生海鲜,把甜虾放到寿喜锅煮熟了吃也是一种美味。一下子炫了有二十只。还有其他的串、板烧之类的,自己还是挺能吃的。但是减肥一直是一个大问题了。(本来先找找有没有照片,发现一张都没拍。)

而之后的日子,再没有出过城了。最远的地方只是去县城的郊区和曾经的高中同学看了一次花。起因是 '浙江“阿勒泰” - 小红书',但是照骗,去了也没找到在哪。

终于把很多人吹爆的「葬送的芙莉莲」第一季追完了,最近联名活动也比较多,只是可惜我居住的地方没有这类活动。人类生命短暂,错过机会会带来遗憾,活在当下,珍惜与亲人共度的时光,这些时刻将成为珍贵的回忆。

https://www.themoviedb.org/tv/209867

上个月说会免费试用 FSD 一个月,但是后面被叫停了。心心念念想体验一次,问问很多次都不能在店里试乘。终于最近有机会能去体验了一把。但是从我的试乘感受上来说,并没有网上说的那么神。首先是,一开始就翻车了,直接闯了红灯,在中间紧急接管刹车。然后又是走错车道,左转走了直行道。最后,在一条施工道路也走错了,跨越实线进入了非机动车道。整体乘坐体验还是挺舒适的,但是科目一确实不及格。

摄影初探

不知道什么时候开始突然有了一个想法,想买个相机了。虽然我一点都不会拍照。之前在上海的朋友用了下富士之后,这个念头更加强烈了。但是在 2025 年的今天想买一个 2020 年生产的机器居然还要溢价购买,或者线下店抽签或者线上点踩点蹲抢真是离了个大谱。又是过了段时间,找了淘宝上的授权店,加了一个 35定镜头,原价购买了 1650 套机。然后学学摄像。没事开车出去可以拍拍景。发几张个人觉得还行的。

:::gallery

:::

项目重启

工作上偶尔需要写写 Swift。空闲之余,我也在再一次入门 Apple 设备的 native 开发。而这次我选择重写两年前写的一个 macOS app - Process Reporter。这个 app 是用来上报当前我正在使用的 app 和 media 到 Mix Space 的。在我的网站上,呈现为:

两年前,我使用不成熟的 SwiftUI 糊了一个,全是 bug,功能也不太全面。而这次我选择用 AppKit 为主,SwiftUI 为辅去重写一个。经历了两周左右的马拉松和慢慢的打磨,现在功能基本差不多了。

这次加强了一下对 media process 的识别。现在可以准确的知道是哪个播放器在放歌了。(PS. macOS 在 15.4 及后续版本对私有接口加强了管制,导致后续版本无法正常识别任何 media 信息了)。另外加上了自动上传 app 的图标到图床的功能,没有预设的 app 也能正常显示图标。

最后,还有一个更好玩的。如果你使用 Slack 办公。那么就能实现这个效果。

非常简单的设置即可实现:

开源在:

https://github.com/Innei/ProcessReporter

看完了?说点什么呢

使用 React Native Screens 构建一个 Native Navigation 之内部原理

2025-04-16 00:04:42

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2

上回说到,我们已经初步了解 React Native Screens 的 ScreenStackItem 用法。这节我们探索一下这个组件的原生实现,然后写一个简单的 Navigation 类。

ScreenStackItem 是怎么实现的

源码定位

进入 React Native Screens 的源码,我们找到这个组件的位置。

我们看到这里使用了 Screen 组件。继续查找。

const Screen = React.forwardRef<View, ScreenProps>((props, ref) => {
  const ScreenWrapper = React.useContext(ScreenContext) || InnerScreen

  return <ScreenWrapper {...props} ref={ref} />
})

Screen 组件使用了 InnerScreen 组件。它是对 NativeView 的进一步封装。

用到了 AnimatedScreen,它针对不同场景使用的 Native 组件不同。但是最终都是一个 Screen 基类。

上面的 AnimatedNativeScreenAnimatedNativeModalScreen 都是实实在在的原生组件了。他们分别对应的是 RNSScreenRNSModalScreen 组件。

以 iOS 为例,找到其原生实现。通过 RSScreen.h 头文件看到,这个组件在原生中是一个 ViewController。所以它才会有这些生命周期事件。例如 viewDidLoadviewWillAppearviewDidAppear 等。


@interface RNSScreen : UIViewController <RNSViewControllerDelegate>

- (instancetype)initWithView:(UIView *)view;
- (UIViewController *)findChildVCForConfigAndTrait:(RNSWindowTrait)trait includingModals:(BOOL)includingModals;
- (BOOL)hasNestedStack;
- (void)calculateAndNotifyHeaderHeightChangeIsModal:(BOOL)isModal;
- (void)notifyFinishTransitioning;
- (RNSScreenView *)screenView;
#ifdef RCT_NEW_ARCH_ENABLED
- (void)setViewToSnapshot;
- (CGFloat)calculateHeaderHeightIsModal:(BOOL)isModal;
#endif

@end

RNSModalScreen 则是继承于 RNSScreen 的。

@interface RNSModalScreen : RNSScreenView
@end

这个为什么需要分别定义两个组件?

在 iOS 中,modal 和普通的 view 有所区别,modal 需要脱离 Root Navigation Controller 进入一个新的 Navigation Controller 中。它是一个孤立的视图。

那么,光有 ViewController 肯定是不行的。我们还缺少一个管理 VC 的 Navigation Controller。还记得之前我们在使用 ScreenStackItem 的时候,需要包一个 ScreenStack 吗?

我们找到它,在 iOS 中对应 RNSScreenStack,它是一个 Navigation Controller。

@interface RNSNavigationController : UINavigationController <RNSViewControllerDelegate, UIGestureRecognizerDelegate>

@end

@interface RNSScreenStackView :
#ifdef RCT_NEW_ARCH_ENABLED
    RCTViewComponentView <RNSScreenContainerDelegate>
#else
    UIView <RNSScreenContainerDelegate, RCTInvalidating>
#endif

- (void)markChildUpdated;
- (void)didUpdateChildren;
- (void)startScreenTransition;
- (void)updateScreenTransition:(double)progress;
- (void)finishScreenTransition:(BOOL)canceled;

@property (nonatomic) BOOL customAnimation;
@property (nonatomic) BOOL disableSwipeBack;

#ifdef RCT_NEW_ARCH_ENABLED
#else
@property (nonatomic, copy) RCTDirectEventBlock onFinishTransitioning;
#endif // RCT_NEW_ARCH_ENABLED

@end

@interface RNSScreenStackManager : RCTViewManager <RCTInvalidating>

@end

页面切换的实现原理

现在 Navigation Controller 和 View Controller 都有了,那么 React Native Screens 是如何管理页面之间的切换并做出动画的呢。

我们知道在 iOS 中,使用在 Navigation Controller 上命令式调用 pushViewController 方法,就可以实现页面之间的切换。但是上一篇文章中的 demo 中,我们并没有调用任何原生方法,只是 React 这边的组件状态发生了更新。

还记得吗,回顾一下。

const Demo = () => {
  const [otherRoutes, setOtherRoutes] = useState<
    {
      screenId: string
      route: ReactNode
    }[]
  >([])
  const cnt = useRef(0)
  const pushNewRoute = useEventCallback(() => {
    const screenId = `new-route-${cnt.current}`
    cnt.current++
    setOtherRoutes((prev) => [
      ...prev,
      {
        screenId,
        route: (
          <ScreenStackItem
            style={StyleSheet.absoluteFill}
            key={prev.length}
            screenId={screenId}
            onDismissed={() => {
              setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
            }}
          >
            <View className="flex-1 items-center justify-center bg-white">
              <Text>New Route</Text>
            </View>
          </ScreenStackItem>
        ),
      },
    ])
  })
  return (
    <ScreenStack style={StyleSheet.absoluteFill}>
      <ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
        <View className="flex-1 items-center justify-center bg-white">
          <Text>Root Route</Text>
          <Button title="Push New Route" onPress={pushNewRoute} />
        </View>
      </ScreenStackItem>
      {otherRoutes.map((route) => route.route)}
    </ScreenStack>
  )
}

我们只是通过更新 React Children 对应有几个 ScreenStackItem 组件,就实现了页面之间的切换。

那么,这个过程到底发生了什么呢?

其实都是在 RNSScreenStack 中处理的,通过比较更新前后的 children 数组,来决定是 push 还是 pop。


- (void)didUpdateReactSubviews
{
  // we need to wait until children have their layout set. At this point they don't have the layout
  // set yet, however the layout call is already enqueued on ui thread. Enqueuing update call on the
  // ui queue will guarantee that the update will run after layout.
  dispatch_async(dispatch_get_main_queue(), ^{
    [self maybeAddToParentAndUpdateContainer];
  });
}


- (void)maybeAddToParentAndUpdateContainer
{
  BOOL wasScreenMounted = _controller.parentViewController != nil;
  if (!self.window && !wasScreenMounted) {
    // We wait with adding to parent controller until the stack is mounted.
    // If we add it when window is not attached, some of the view transitions will be blocked (i.e.
    // modal transitions) and the internal view controler's state will get out of sync with what's
    // on screen without us knowing.
    return;
  }
  [self updateContainer];
  if (!wasScreenMounted) {
    // when stack hasn't been added to parent VC yet we do two things:
    // 1) we run updateContainer (the one above) – we do this because we want push view controllers to
    // be installed before the VC is mounted. If we do that after it is added to parent the push
    // updates operations are going to be blocked by UIKit.
    // 2) we add navigation VS to parent – this is needed for the VC lifecycle events to be dispatched
    // properly
    // 3) we again call updateContainer – this time we do this to open modal controllers. Modals
    // won't open in (1) because they require navigator to be added to parent. We handle that case
    // gracefully in setModalViewControllers and can retry opening at any point.
    [self reactAddControllerToClosestParent:_controller];
    [self updateContainer];
  }
}


- (void)updateContainer
{
  NSMutableArray<UIViewController *> *pushControllers = [NSMutableArray new];
  NSMutableArray<UIViewController *> *modalControllers = [NSMutableArray new];
  for (RNSScreenView *screen in _reactSubviews) {
    if (!screen.dismissed && screen.controller != nil && screen.activityState != RNSActivityStateInactive) {
      if (pushControllers.count == 0) {
        // first screen on the list needs to be places as "push controller"
        [pushControllers addObject:screen.controller];
      } else {
        if (screen.stackPresentation == RNSScreenStackPresentationPush) {
          [pushControllers addObject:screen.controller];
        } else {
          [modalControllers addObject:screen.controller];
        }
      }
    }
  }

  [self setPushViewControllers:pushControllers];
  [self setModalViewControllers:modalControllers];
}

当 React 组件的 children 发生变化会调用 didUpdateReactSubviews 方法。然后最后进入到 updateContainer 方法中。

updateContainer 方法中,会根据 RNSScreenViewstackPresentation 属性,来决定是 push 还是 pop。然后调用 setPushViewControllers 或者 setModalViewControllers 方法,来更新原生的视图。

setPushViewControllers 方法中调用原生的 pushViewController 方法。

所以,在 Native 中,整个 Navigation Controller 都是无状态的,他虽然存储 Controller 数组,但是只会比较前后得出需要过度的页面。

这也导致了,在 React 中如果你没有管理 ScreenStackItem,在触发 dismiss 之后,虽然看到页面返回了,但是再次点击进入之后就会推入比上一次 +1 的页面。

也因为这个原因,在 onDismissed 事件中,Native 无法告诉 React Native 这边被 dismiss 的页面是哪个,而是只能提供 dismiss 的数量。

onDismissed?: (e: NativeSyntheticEvent<{ dismissCount: number }>) => void;

下一步计划

好了,这篇文章就到这里了。篇幅已经有点长了。

那么,下一篇文章,我们再来实现一个简单的 Navigation 类吧。

看完了?说点什么呢