跳到主要内容

215 篇博文 含有标签「iCoding」

个人简介

查看所有标签

想通过命令行安装特定版本的 Android NDK

· 阅读需 3 分钟
Quany
软件工程师

您想通过命令行安装特定版本的 Android NDK,但需要先澄清一个关键点:adb 命令主要用于与已连接的 Android 设备通信,而安装 NDK 是在您的本地开发电脑上进行的操作。正确的工具是 Android SDK 的 sdkmanager

下面为您详细介绍两种方案来满足您的需求。

🔧 方案一:使用 sdkmanager 安装指定版本(推荐)

这是最直接、由 Android 官方推荐的方法。sdkmanager 是一个专门用于管理 Android SDK 包(包括 NDK)的命令行工具。

  1. 定位 sdkmanager:首先,您需要找到您电脑上 Android SDK 目录下的 sdkmanager 工具。它通常位于 android-sdk/tools/bin/ 目录下。如果您设置了 ANDROID_SDK_ROOTANDROID_HOME 环境变量,会更容易定位。

  2. 执行安装命令:打开终端(或命令提示符),使用以下命令格式进行安装。请注意,sdkmanager 期望的版本字符串格式可能与您提供的略有不同,它通常遵循 major.minor.revision 的模式。

    sdkmanager "ndk;21.0.6113669"

    在命令执行过程中,系统会显示许可协议,您需要接受才能继续安装。您也可以在命令中直接加上 --accept_licenses 参数来自动接受。

  3. 验证安装:安装完成后,您可以在 $ANDROID_SDK_ROOT/ndk/ 目录下找到一个名为 21.0.6113669 的文件夹,这就是安装好的 NDK。

⚙️ 方案二:配置项目使用已安装的 NDK

如果您的开发环境中已经安装了多个版本的 NDK(您的错误信息显示本地有众多版本),另一个高效的策略是直接修改项目配置,使其使用一个已有的、兼容的版本,而不是重新安装。

  • 修改项目配置:在您项目模块(通常是 app)级的 build.gradle 文件中,指定一个您本地已有的 NDK 版本。从您的错误日志看,21.3.6528147 是一个很接近的选择。

    android {
    ndkVersion "21.3.6528147" // 指定一个本地已有的兼容版本
    }

💎 方案对比与建议

为了让您更清晰地选择,这里有一个简单的对比表格:

方案核心命令/操作优点适用场景
方案一sdkmanager "ndk;21.0.6113669"官方推荐,直接满足版本要求需要精确匹配特定 NDK 版本时
方案二build.gradle 中设置 ndkVersion快速,无需下载,利用现有资源项目有一定灵活性,允许使用兼容版本

综合建议

  • 如果您确定项目必须使用 21.0.6113669 这个特定版本,请使用方案一
  • 如果希望快速解决问题,并且经过测试后确认使用一个已有的高版本(如 21.3.6528147)也能成功编译,那么方案二是更高效的选择。

⚠️ 重要注意事项

  • 确保 sdkmanager 可用:在执行方案一的命令前,请确认 sdkmanager 在您的系统环境变量 PATH 中,或者您需要切换到其所在目录执行。
  • 版本可用性:极老的或非常规的 NDK 版本可能已从官方仓库中移除。如果 sdkmanager 提示找不到该版本,您可能需要从其他渠道手动下载并配置。
  • AGP 版本:您遇到这个问题的原因可能是项目使用的 Android Gradle Plugin (AGP) 版本较老,其默认的 NDK 版本恰好是 21.0.6113669。长远考虑,升级 AGP 版本是一个更好的选择,因为它会自动管理并使用更新的、兼容性更好的 NDK。

希望这些方案能帮助您解决问题!如果您在操作中遇到任何其他情况,欢迎随时提出。

微信公众号

微信公众号

React Native 0.83

· 阅读需 4 分钟
Quany
软件工程师

React 19.2 的新特性确实为 React Native 开发带来了更优雅的解决方案,特别是 useEffectEvent,它能显著改善副作用管理和状态处理。下面的表格总结了这些特性的核心用途和兼容性,方便你快速了解。

特性 核心用途 React Native 兼容性

useEffectEvent 从 Effect 中分离“事件逻辑”,解决闭包陷阱,简化依赖数组 需 RN 0.83+(已集成 React 19.2)

Activity 组件 条件渲染时隐藏而非卸载组件,以保留状态(如表单输入、滚动位置) 需 RN 0.83+(已集成 React 19.2)

React Compiler 1.0 构建时自动优化重渲染,减少手动useMemo, useCallback使用 需项目配置启用(目前仍为实验性)

性能工具增强 新的 DevTools 提供更强大的性能追踪和网络请求调试能力 需 RN 0.83+

💡 核心特性详解与应用

  1. useEffectEvent:告别闭包陷阱

useEffectEvent 是 React 19.2 中一个解决常见副作用的 Hook,它允许你将一个包含“非响应式”逻辑(即那些不应该导致副作用重新执行的逻辑)的函数标记为 “Effect 事件”。

• 解决的问题:在以往的 React 开发中,如果一个 Effect 内部使用的函数引用了组件的 props 或 state,你必须将该函数放入 Effect 的依赖数组中。但这常常会导致 Effect 过度重复执行。使用 useRef 手动管理最新值虽然可行,但增加了代码的复杂度和出错风险。

• React Native 使用示例:一个典型的场景是处理 WebSocket 消息。你希望 WebSocket 连接只在 roomId 改变时重建,但消息处理函数又需要能访问到最新的 messages 状态。

    // 注意:此示例基于 React 19.2 的 Canary 版本,使用时请确认你的 React 版本
import { useState, useEffect, useEffectEvent } from 'react';
import { View, Text } from 'react-native';

function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);

// 使用 useEffectEvent 包裹消息处理逻辑
const handleNewMessage = useEffectEvent((newMessage) => {
// 此函数总能访问到最新的 messages 状态
setMessages((currentMessages) => [...currentMessages, newMessage]);
});

useEffect(() => {
const socket = new WebSocket(`wss://myapi.com/chat/${roomId}`);

socket.onmessage = (event) => {
const messageData = JSON.parse(event.data);
handleNewMessage(messageData); // 在 Effect 内部调用
};

return () => socket.close();
}, [roomId]); // 依赖数组只需包含 roomId,handleNewMessage 不需要加入

return (
<View>
{messages.map(msg => <Text key={msg.id}>{msg.text}</Text>)}
</View>
);
}

• 重要限制:useEffectEvent 返回的函数必须仅在 Effect 内部调用,而不能直接作为如 onPress 这类事件处理函数使用。

  1. Activity 组件:优雅的状态保持

Activity 组件提供了一种更优雅的方式来条件性地渲染组件,其核心优势在于当组件被隐藏时,它不会被卸载,从而完美保留所有状态。

• 解决的问题:在 React Native 中,我们常用条件渲染 。但当 isVisible 变为 false 时,组件会被卸载,其内部状态(如输入框的内容、滚动位置)会完全丢失。再次显示时,组件会重新挂载,状态被重置。

• React Native 使用示例:在具有多个标签页的界面中,使用 Activity 可以确保切换标签时各个标签页的状态得以保留。

    // 注意:此组件在 React 19.2 中引入
import { Activity } from 'react';
import { View, Text, TextInput } from 'react-native';

function TabScreen({ isActive }) {
const [inputValue, setInputValue] = useState(''); // 这个状态在标签隐藏时不会丢失

return (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<View>
<Text>这个标签页的状态会被保留</Text>
<TextInput
value={inputValue}
onChangeText={setInputValue}
placeholder="尝试输入后切换标签..."
/>
</View>
</Activity>
);
}

🔐 使用前的重要检查

在将 React 19.2 的新特性用于 React Native 项目前,请务必确认以下两点:

  1. React Native 版本:确保你的项目使用的是 React Native 0.83 或更高版本,因为该版本才开始集成 React 19.2。你可以通过查看 package.json 文件中的 react-native 依赖版本来确认。
  2. React 版本:确认项目的 react 和 react-native 包版本兼容。React Native 0.83 内置了兼容的 React 版本。如果你想在更早的 RN 版本中尝试,需要手动升级 React 到 19.2,但这可能会带来风险,强烈建议通过升级 RN 版本来获取稳定支持。

希望这些具体的示例能帮助你在 React Native 项目中更好地运用 React 19.2 的新特性。如果你在升级版本或使用特定特性时遇到具体问题,我很乐意提供进一步的探讨。

微信公众号

微信公众号

App 系统架构设计

· 阅读需 14 分钟
Quany
软件工程师

1. 概述

本项目是一个基于 React Native 的移动端销售管理系统,模仿纷享销客 App 的功能和界面设计,主要服务于企业销售团队的日常管理工作。

1.1 技术栈

类别技术选型版本
框架React Native0.82.1
语言TypeScript5.8.x
状态管理Zustand5.x
导航React Navigation7.x
UI 组件库@rneui/themed4.x
样式方案NativeWind (Tailwind)4.x
HTTP 客户端Axios1.7.x
表单处理Formik2.x
国际化react-i18next16.x
存储AsyncStorage + Keychain-
热更新CodePush-

2. 整体架构图

┌─────────────────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Navigation (React Navigation) ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ HomeTabs │ │ Login │ │ WebView │ │ Setting │ ││
│ │ │ (企信/工作台│ │ Register │ │ Browser │ │ Profile │ ││
│ │ │ /CRM/我) │ │ │ │ │ │ │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ UI Components ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │ Common │ │ Form │ │ Message │ │ Layout │ │ Business │ ││
│ │ │Components│ │Components│ │Components│ │Components│ │Components│ ││
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│ Business Layer │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ State Management ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ AuthStore │ │ ChatStore │ │ UserStore │ │ BusinessStore│ ││
│ │ │ (认证状态) │ │ (聊天状态) │ │ (用户状态) │ │ (业务状态) │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Services ││
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││
│ │ │ AuthService │ │ UserService │ │OrderService │ │CacheService │ ││
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ API Client ││
│ │ ┌───────────────────────────────────────────────────────────────────┐ ││
│ │ │ Axios Instance (拦截器、Token管理、错误处理、Loading状态) │ ││
│ │ └───────────────────────────────────────────────────────────────────┘ ││
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ││
│ │ │OrderAPI │ │PartnerAPI││CustomerAPI││ApprovalAPI││EmployeeAPI│ ││
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────────────────────┐│
│ │ Local Storage ││
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ││
│ │ │ AsyncStorage │ │ Keychain │ │ react-native- │ ││
│ │ │ (用户偏好/缓存) │ │ (安全凭证存储) │ │ storage │ ││
│ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Theme │ │ i18n │ │ Config │ │ Utils │ │ Types │ │
│ │ (主题) │ │ (国际化) │ │ (配置) │ │ (工具) │ │ (类型) │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

3. 目录结构设计

src/
├── api/ # API 层
│ ├── client.ts # Axios 客户端配置
│ ├── index.ts # API 统一导出
│ ├── sales.ts # 销售相关 API
│ ├── customer.ts # 客户相关 API (建议拆分)
│ ├── order.ts # 订单相关 API (建议拆分)
│ └── types/ # API 请求/响应类型定义
│ ├── request.ts
│ └── response.ts

├── components/ # 通用组件
│ ├── common/ # 基础通用组件
│ │ ├── Button/
│ │ ├── Card/
│ │ ├── Empty/
│ │ ├── ErrorBoundary/
│ │ └── index.ts
│ │
│ ├── form/ # 表单组件
│ │ ├── FormInput.tsx
│ │ ├── PasswordInput.tsx
│ │ ├── PhoneInput.tsx
│ │ ├── AgreementCheckbox.tsx
│ │ └── index.ts
│ │
│ ├── layout/ # 布局组件
│ │ ├── Header/
│ │ ├── TabBar/
│ │ ├── SafeContainer/
│ │ └── index.ts
│ │
│ ├── business/ # 业务组件
│ │ ├── CustomerCard/
│ │ ├── OrderItem/
│ │ ├── ApprovalCard/
│ │ └── index.ts
│ │
│ └── message/ # 消息相关组件
│ ├── MessageItem/
│ ├── ChatBubble/
│ └── index.ts

├── screens/ # 页面/屏幕
│ ├── user/ # 用户相关页面
│ │ ├── Login.tsx
│ │ ├── Register.tsx
│ │ └── Profile.tsx
│ │
│ ├── home/ # 主页 Tab 页面
│ │ ├── index.tsx # Tab 导航配置
│ │ └── tabs/
│ │ ├── CorporateMessage.tsx # 企信
│ │ ├── Workbench.tsx # 工作台
│ │ ├── Crm.tsx # CRM
│ │ └── My.tsx # 我
│ │
│ ├── crm/ # CRM 模块页面
│ │ ├── CustomerList/
│ │ ├── CustomerDetail/
│ │ ├── OrderList/
│ │ └── OrderDetail/
│ │
│ ├── approval/ # 审批模块页面
│ │ ├── ApprovalList/
│ │ └── ApprovalDetail/
│ │
│ └── common/ # 通用页面
│ ├── Setting.tsx
│ ├── SplashScreen.tsx
│ ├── PDFViewer.tsx
│ └── web/
│ └── index.tsx

├── navigation/ # 导航配置
│ ├── index.tsx # 根导航器
│ ├── types.ts # 导航类型定义
│ ├── linking.ts # 深度链接配置
│ └── helpers.ts # 导航辅助函数

├── store/ # 状态管理 (Zustand)
│ ├── index.ts # Store 工具函数
│ ├── authStore.ts # 认证状态
│ ├── userStore.ts # 用户状态
│ ├── chatStore.ts # 聊天状态
│ └── types.ts # Store 类型定义

├── services/ # 业务服务层
│ ├── authService.ts # 认证服务
│ ├── userService.ts # 用户服务
│ ├── storageService.ts # 存储服务
│ └── pushService.ts # 推送服务

├── hooks/ # 自定义 Hooks
│ ├── useAuth.ts
│ ├── useUser.ts
│ ├── useToast.ts
│ ├── useRefresh.ts
│ └── index.ts

├── contexts/ # React Context
│ ├── AuthContext.tsx
│ ├── UserContext.tsx
│ ├── ChatContext.tsx
│ └── index.ts

├── theme/ # 主题配置
│ ├── index.ts # 主题导出
│ ├── colors.ts # 颜色定义
│ ├── typography.ts # 字体定义
│ ├── spacing.ts # 间距定义
│ ├── shadows.ts # 阴影定义
│ └── ThemeContext.tsx # 主题 Context

├── constants/ # 常量定义
│ ├── config.ts # 应用配置
│ ├── sizes.ts # 尺寸常量
│ ├── api.ts # API 相关常量
│ └── index.ts

├── types/ # 全局类型定义
│ ├── api.ts # API 类型
│ ├── navigation.ts # 导航类型
│ ├── models/ # 数据模型类型
│ │ ├── user.ts
│ │ ├── customer.ts
│ │ ├── order.ts
│ │ └── index.ts
│ └── index.ts

├── utils/ # 工具函数
│ ├── env.ts # 环境变量工具
│ ├── storage.ts # 存储工具
│ ├── timezone.ts # 时区工具
│ ├── toast.ts # Toast 工具
│ ├── format.ts # 格式化工具
│ ├── validation.ts # 验证工具
│ └── index.ts

├── locales/ # 国际化资源
│ ├── en.json
│ ├── zh.json
│ └── index.ts

└── index.tsx # 应用入口

4. 分层架构详解

4.1 表现层 (Presentation Layer)

负责 UI 渲染和用户交互。

// screens/crm/CustomerList/index.tsx
import { useCustomerList } from '@/hooks/useCustomer';
import { CustomerCard } from '@/components/business';
import { SafeContainer, EmptyState } from '@/components/layout';

export default function CustomerList() {
const { data, loading, refresh } = useCustomerList();

if (loading) return <Loading />;
if (!data?.length) return <EmptyState type="customer" />;

return (
<SafeContainer>
<FlatList
data={data}
renderItem={({ item }) => <CustomerCard customer={item} />}
onRefresh={refresh}
/>
</SafeContainer>
);
}

4.2 业务逻辑层 (Business Layer)

4.2.1 状态管理 (Zustand Store)

// store/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface AuthState {
isAuthenticated: boolean;
token: string | null;
user: UserInfo | null;
}

interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<void>;
setUser: (user: UserInfo) => void;
}

export const useAuthStore = create<AuthState & AuthActions>()(
persist(
immer((set, get) => ({
// State
isAuthenticated: false,
token: null,
user: null,

// Actions
async login(email: string, password: string) {
const { token, user } = await authService.login(email, password);
set(state => {
state.isAuthenticated = true;
state.token = token;
state.user = user;
});
},

async logout() {
await authService.logout();
set(state => {
state.isAuthenticated = false;
state.token = null;
state.user = null;
});
},

async refreshToken() {
const newToken = await authService.refreshToken(get().token);
set(state => {
state.token = newToken;
});
},

setUser(user: UserInfo) {
set(state => {
state.user = user;
});
},
})),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ token: state.token, user: state.user }),
}
)
);

4.2.2 服务层 (Services)

// services/authService.ts
import * as Keychain from 'react-native-keychain';
import client from '@/api/client';
import Storage from '@/utils/storage';

export interface AuthService {
login(email: string, password: string): Promise<LoginResult>;
logout(): Promise<void>;
refreshToken(token: string | null): Promise<string>;
validateToken(): Promise<boolean>;
}

export const authService: AuthService = {
async login(email: string, password: string) {
// 1. 验证凭证
const validateRes = await client.post('/login/validate', { email, password });

// 2. 获取 Token
const loginRes = await client.post('/login/email', {
...validateRes.data,
email,
});

// 3. 安全存储 Token
await Keychain.setGenericPassword('auth', loginRes.token);

// 4. 存储用户信息
await Storage.save({
key: 'userInfo',
data: loginRes.user,
});

return loginRes;
},

async logout() {
await Keychain.resetGenericPassword();
await Storage.remove({ key: 'userInfo' });
},

async refreshToken(token) {
const res = await client.post('/auth/refresh', { token });
await Keychain.setGenericPassword('auth', res.token);
return res.token;
},

async validateToken() {
try {
const credentials = await Keychain.getGenericPassword();
if (!credentials) return false;

const res = await client.post('/auth/validate');
return res.valid === true;
} catch {
return false;
}
},
};

4.3 数据层 (Data Layer)

4.3.1 API 客户端

// api/client.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import Config from 'react-native-config';
import * as Keychain from 'react-native-keychain';
import { Loader } from '@/components/loading';
import { showToast } from '@/utils/toast';
import { reset } from '@/navigation';

// 创建实例
const client: AxiosInstance = axios.create({
baseURL: Config.SALES_SERVER,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});

// 请求拦截器
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// 1. 处理 Loading
if (!config.hideLoading) {
Loader.current?.show();
}

// 2. 注入 Token
const credentials = await Keychain.getGenericPassword();
if (credentials?.password) {
config.headers['vulcan-token'] = credentials.password;
}

// 3. 注入通用请求头
config.headers['accept-timezone'] = await getTimezone();
config.headers['language-code'] = 'zh-CN';

return config;
},
(error) => Promise.reject(error)
);

// 响应拦截器
client.interceptors.response.use(
(response) => {
Loader.current?.hide();

const { code, data, msg } = response.data;

if (code === 200) {
return data;
}

// 业务错误处理
if (code === 401) {
showToast('登录已过期');
reset({ index: 0, routes: [{ name: 'Login' }] });
} else {
showToast(msg || '请求失败');
}

return Promise.reject(response.data);
},
(error) => {
Loader.current?.hide();

if (error.response?.status === 401) {
reset({ index: 0, routes: [{ name: 'Login' }] });
}

showToast(error.message || '网络错误');
return Promise.reject(error);
}
);

export default client;

4.3.2 API 模块化

// api/customer.ts
import client from './client';
import type { Customer, CustomerListParams, CustomerListResponse } from '@/types/models';

export const customerApi = {
/**
* 获取客户列表
*/
list(params: CustomerListParams): Promise<CustomerListResponse> {
return client.post('/web/customer/list', params);
},

/**
* 获取客户详情
*/
detail(customerId: string): Promise<Customer> {
return client.post('/web/customer/info', { customerId });
},

/**
* 创建/更新客户
*/
save(data: Partial<Customer>): Promise<Customer> {
return client.post('/web/customerManagement/createOrModifyCustomer', data);
},

/**
* 今日统计
*/
todayStatistics(): Promise<CustomerStatistics> {
return client.get('/web/customer/todayStatistics');
},
};

5. 导航架构

5.1 导航结构图

RootNavigator (Native Stack)
├── SplashScreen # 启动屏
├── AuthNavigator (Stack) # 认证流程
│ ├── Login # 登录
│ └── Register # 注册
├── MainNavigator (Stack) # 主应用
│ ├── HomeTabs (Bottom Tab) # 主页 Tabs
│ │ ├── CorporateMessage # 企信
│ │ ├── Workbench # 工作台
│ │ ├── CRM # CRM
│ │ └── My # 我
│ ├── CRMNavigator (Stack) # CRM 模块
│ │ ├── CustomerList
│ │ ├── CustomerDetail
│ │ ├── OrderList
│ │ └── OrderDetail
│ ├── ApprovalNavigator (Stack) # 审批模块
│ │ ├── ApprovalList
│ │ └── ApprovalDetail
│ └── CommonScreens # 通用页面
│ ├── Setting
│ ├── WebView
│ └── PDFViewer
└── Modal Screens # 模态页面
├── ImageViewer
└── ActionSheet

5.2 导航类型定义

// navigation/types.ts
import type { NavigatorScreenParams } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';

// Root Navigator
export type RootStackParamList = {
SplashScreen: undefined;
Login: undefined;
Register: undefined;
HomeTabs: NavigatorScreenParams<HomeTabParamList>;
CustomerDetail: { customerId: string };
OrderDetail: { orderId: string };
Setting: undefined;
WebView: { url: string; title?: string };
};

// Home Tab Navigator
export type HomeTabParamList = {
CorporateMessage: undefined;
Workbench: undefined;
CRM: undefined;
My: undefined;
};

// Screen Props 类型
export type RootStackScreenProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;

// 扩展全局导航类型
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

6. 数据流设计

6.1 单向数据流

┌─────────────────────────────────────────────────────────────────┐
│ 用户交互 │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ UI Component │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Props │ ← │ Store │ ← │ Service │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Action Dispatch │ │
│ │ onClick → store.action() → API Call → Update State │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ State Update │ │
│ │ Zustand: set(state => { state.xxx = newValue }) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Re-render │ │
│ │ UI Component 自动订阅状态变化,触发重新渲染 │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

6.2 异步数据流示例

// hooks/useCustomerList.ts
import { useState, useCallback } from 'react';
import { customerApi } from '@/api/customer';
import { useToast } from './useToast';

export function useCustomerList(initialParams?: CustomerListParams) {
const [data, setData] = useState<Customer[]>([]);
const [loading, setLoading] = useState(false);
const [params, setParams] = useState(initialParams || { pageNum: 1, pageSize: 20 });
const { showError } = useToast();

const fetch = useCallback(async (newParams?: Partial<CustomerListParams>) => {
const mergedParams = { ...params, ...newParams };
setLoading(true);

try {
const res = await customerApi.list(mergedParams);

if (mergedParams.pageNum === 1) {
setData(res.list);
} else {
setData(prev => [...prev, ...res.list]);
}

setParams(mergedParams);
return res;
} catch (error) {
showError('加载失败');
throw error;
} finally {
setLoading(false);
}
}, [params]);

const refresh = useCallback(() => fetch({ pageNum: 1 }), [fetch]);

const loadMore = useCallback(() => {
return fetch({ pageNum: params.pageNum + 1 });
}, [fetch, params.pageNum]);

return { data, loading, fetch, refresh, loadMore };
}

7. 核心模块设计

7.1 认证模块

┌─────────────────────────────────────────────────────────────┐
│ Authentication Flow │
│ │
│ ┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ App │ → │ Splash │ → │ Check │ → │ Navigate │ │
│ │ 启动 │ │ Screen │ │ Token │ │ to... │ │
│ └──────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ │ │ │
│ Token 有效 Token 无效 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ HomeTabs │ │ Login │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Validate │ │
│ │ Email & │ │
│ │ Password │ │
│ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Login │ │
│ │ with │ │
│ │ VerifyCode│ │
│ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ Save │ │
│ │ Token & │ │
│ │ UserInfo │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘

7.2 CRM 模块

┌─────────────────────────────────────────────────────────────┐
│ CRM Module │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 客户管理 ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │ 客户列表 │ │ 客户详情 │ │ 客户编辑 │ ││
│ │ │ - 分页 │ │ - 基本信息│ │ - 表单 │ ││
│ │ │ - 搜索 │ │ - 品牌 │ │ - 验证 │ ││
│ │ │ - 筛选 │ │ - 项目 │ │ - 提交 │ ││
│ │ └──────────┘ │ - 合同 │ └──────────┘ ││
│ │ │ - 订单 │ ││
│ │ └──────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 订单管理 ││
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││
│ │ │ 订单列表 │ │ 订单详情 │ │ 创建订单 │ ││
│ │ │ - 状态筛选│ │ - 产品 │ │ - 选客户 │ ││
│ │ │ - 时间筛选│ │ - 金额 │ │ - 选产品 │ ││
│ │ │ - 导出 │ │ - 审批流程│ │ - 提交 │ ││
│ │ └──────────┘ └──────────┘ └──────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 审批管理 ││
│ │ ┌──────────┐ ┌──────────┐ ││
│ │ │ 待办审批 │ │ 审批详情 │ ││
│ │ │ - 我发起的│ │ - 同意 │ ││
│ │ │ - 待我审批│ │ - 拒绝 │ ││
│ │ │ - 我审批的│ │ - 转交 │ ││
│ │ └──────────┘ └──────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘

7.3 消息模块

┌─────────────────────────────────────────────────────────────┐
│ Message Module │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 企业消息 (企信) ││
│ │ ││
│ │ 消息类型: ││
│ │ - 待办事项通知 ││
│ │ - CRM 消息通知 ││
│ │ - 审批消息 ││
│ │ - 日程提醒 ││
│ │ - 系统通知 ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 实时通信 ││
│ │ ││
│ │ 技术方案: SSE (Server-Sent Events) ││
│ │ ││
│ │ ┌────────────────────────────────────────────────────┐││
│ │ │ Client Server │││
│ │ │ │ │ │││
│ │ │ │ ── POST /chat/msg ──────────> │ │││
│ │ │ │ │ │││
│ │ │ │ <── GET /chat/sse?msgId=xxx ─ │ │││
│ │ │ │ │ │││
│ │ │ │ <── [Event: message] ──────── │ │││
│ │ │ │ <── [Event: message] ──────── │ │││
│ │ │ │ <── [Event: done] ─────────── │ │││
│ │ │ │ │ │││
│ │ └────────────────────────────────────────────────────┘││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘

8. 主题与样式系统

8.1 设计 Token

// theme/colors.ts
export const colors = {
// 品牌色 - 纷享销客橙色系
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',

// 功能色
success: '#1CC860',
warning: '#FE7E04',
error: '#FC5A5A',
info: '#2196F3',

// 中性色
text: '#192A3E',
textSecondary: '#666666',
textLight: '#999999',
textDisabled: '#D9D9D9',

// 背景色
background: '#F7F8FA',
backgroundSecondary: '#FFFFFF',
white: '#FFFFFF',

// 边框色
border: '#E5E5E5',
divider: '#E0E0E0',

// 渐变色
backgroundGradient: ['#d6edfb', '#f6f5fa', '#f8e6da'],
};

// theme/spacing.ts
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};

// theme/typography.ts
export const typography = {
h1: { fontSize: 24, fontWeight: '700' as const },
h2: { fontSize: 20, fontWeight: '600' as const },
h3: { fontSize: 18, fontWeight: '600' as const },
body: { fontSize: 16, fontWeight: '400' as const },
bodySmall: { fontSize: 14, fontWeight: '400' as const },
caption: { fontSize: 12, fontWeight: '400' as const },
};

8.2 样式方案

项目采用 NativeWind (Tailwind CSS) + StyleSheet 混合方案:

// 推荐方式:Tailwind 类名
function Card() {
return (
<View className="bg-white rounded-lg p-4 shadow-md">
<Text className="text-lg font-semibold text-gray-800">
标题
</Text>
</View>
);
}

// 复杂样式:StyleSheet
const styles = StyleSheet.create({
container: {
...shadow.md,
backgroundColor: colors.white,
borderRadius: borderRadius.lg,
},
});

9. 错误处理策略

9.1 全局错误边界

// components/common/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { View, Text, Button } from 'react-native';

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 上报错误到 Sentry
console.error('Error caught by boundary:', error, errorInfo);
}

handleRetry = () => {
this.setState({ hasError: false, error: null });
};

render() {
if (this.state.hasError) {
return this.props.fallback || (
<View className="flex-1 items-center justify-center p-4">
<Text className="text-lg font-semibold mb-2">出错了</Text>
<Text className="text-gray-500 mb-4">
{this.state.error?.message || '未知错误'}
</Text>
<Button title="重试" onPress={this.handleRetry} />
</View>
);
}

return this.props.children;
}
}

9.2 API 错误处理

// utils/errorHandler.ts
export enum ErrorCode {
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
SERVER_ERROR = 500,
NETWORK_ERROR = -1,
}

export function handleApiError(error: any): string {
const code = error.response?.status || error.code;

switch (code) {
case ErrorCode.UNAUTHORIZED:
// 跳转登录页
reset({ index: 0, routes: [{ name: 'Login' }] });
return '登录已过期,请重新登录';

case ErrorCode.FORBIDDEN:
return '您没有权限执行此操作';

case ErrorCode.NOT_FOUND:
return '请求的资源不存在';

case ErrorCode.SERVER_ERROR:
return '服务器错误,请稍后重试';

case ErrorCode.NETWORK_ERROR:
return '网络连接失败,请检查网络';

default:
return error.message || '请求失败,请稍后重试';
}
}

10. 性能优化策略

10.1 列表性能优化

// 使用 React.memo 避免不必要的重渲染
const CustomerItem = React.memo<{ customer: Customer }>(({ customer }) => {
return (
<View className="p-4 bg-white border-b border-gray-100">
<Text className="font-semibold">{customer.name}</Text>
</View>
);
});

// FlatList 优化配置
<FlatList
data={customers}
renderItem={({ item }) => <CustomerItem customer={item} />}
keyExtractor={(item) => item.id}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={5}
initialNumToRender={10}
/>

10.2 图片优化

// 使用 FastImage 替代 Image
import FastImage from '@d11/react-native-fast-image';

<FastImage
source={{
uri: imageUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
}}
resizeMode={FastImage.resizeMode.cover}
style={{ width: 100, height: 100 }}
/>

10.3 状态更新优化

// 使用 useMemo 缓存计算结果
const filteredCustomers = useMemo(() => {
return customers.filter(c =>
c.name.includes(searchText) || c.phone?.includes(searchText)
);
}, [customers, searchText]);

// 使用 useCallback 缓存回调函数
const handlePress = useCallback(() => {
navigation.navigate('CustomerDetail', { customerId: customer.id });
}, [customer.id]);

11. 安全策略

11.1 敏感数据存储

// 使用 Keychain 存储敏感信息
import * as Keychain from 'react-native-keychain';

// 存储 Token
await Keychain.setGenericPassword('auth', token, {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED,
});

// 读取 Token
const credentials = await Keychain.getGenericPassword();

// 删除 Token
await Keychain.resetGenericPassword();

11.2 输入验证

// 使用 Zod 进行输入验证
import { z } from 'zod';

const loginSchema = z.object({
email: z.string().email('请输入有效的邮箱地址'),
password: z.string().min(6, '密码至少6位'),
});

function validateLogin(data: unknown) {
const result = loginSchema.safeParse(data);
if (!result.success) {
throw new Error(result.error.errors[0].message);
}
return result.data;
}

12. 测试策略

12.1 单元测试

// __tests__/services/authService.test.ts
import { authService } from '@/services/authService';

describe('AuthService', () => {
it('should login successfully with valid credentials', async () => {
const result = await authService.login('test@example.com', 'password123');
expect(result.token).toBeDefined();
expect(result.user).toBeDefined();
});

it('should throw error with invalid credentials', async () => {
await expect(
authService.login('invalid@example.com', 'wrong')
).rejects.toThrow();
});
});

12.2 组件测试

// __tests__/components/CustomerCard.test.tsx
import { render, fireEvent } from '@testing-library/react-native';
import { CustomerCard } from '@/components/business';

const mockCustomer = {
id: '1',
name: '测试客户',
phone: '13800138000',
};

describe('CustomerCard', () => {
it('should render customer info correctly', () => {
const { getByText } = render(<CustomerCard customer={mockCustomer} />);
expect(getByText('测试客户')).toBeTruthy();
expect(getByText('13800138000')).toBeTruthy();
});

it('should call onPress when pressed', () => {
const onPress = jest.fn();
const { getByTestId } = render(
<CustomerCard customer={mockCustomer} onPress={onPress} />
);
fireEvent.press(getByTestId('customer-card'));
expect(onPress).toHaveBeenCalledWith(mockCustomer);
});
});

13. 部署与发布

13.1 多环境配置

# .env.dev
SALES_SERVER=https://dev-api.example.com
LOGIN_SERVER=https://dev-login.example.com
BUNDLE_ENV=dev

# .env.staging
SALES_SERVER=https://staging-api.example.com
LOGIN_SERVER=https://staging-login.example.com
BUNDLE_ENV=staging

# .env.prod
SALES_SERVER=https://api.example.com
LOGIN_SERVER=https://login.example.com
BUNDLE_ENV=prod

13.2 热更新 (CodePush)

# 发布到测试环境
appcenter codepush release-react -a {app-name} -d Staging \
--description "功能更新..." \
-t "1.0.0 - 1.0.5"

# 发布到生产环境
appcenter codepush release-react -a {app-name} -d Production \
--description "功能更新..." \
-t "1.0.0"

14. 后续优化建议

14.1 短期优化 (1-2周)

  1. API 模块拆分: 将 sales.js 拆分为独立的 API 模块 (customer.ts, order.ts, approval.ts)
  2. 类型完善: 为所有 API 请求/响应添加 TypeScript 类型定义
  3. 错误边界: 为关键页面添加 ErrorBoundary 组件
  4. 加载状态: 添加骨架屏 (Skeleton) 组件改善加载体验

14.2 中期优化 (1-2月)

  1. 离线支持: 添加数据缓存和离线访问能力
  2. 性能监控: 接入 Sentry 或 Firebase Performance
  3. 自动化测试: 完善单元测试和集成测试覆盖率
  4. 深度链接: 实现 Universal Links 和 Deep Links

14.3 长期规划

  1. 微前端架构: 考虑按业务模块拆分独立子应用
  2. 跨平台复用: 探索 React Native Web 支持
  3. CI/CD 完善: 自动化构建、测试、发布流程
  4. 设计系统: 建立完整的组件设计系统文档

附录

A. 命名规范

类型规范示例
文件夹kebab-casecustomer-list/
组件文件PascalCaseCustomerCard.tsx
工具文件camelCaseformatDate.ts
常量UPPER_SNAKE_CASEAPI_TIMEOUT
类型/接口PascalCaseCustomerInfo
变量camelCasecustomerList
Boolean 变量is/has/can 前缀isLoading, hasError

B. Git 提交规范

feat: 新功能
fix: 修复 bug
docs: 文档更新
style: 代码格式
refactor: 重构
perf: 性能优化
test: 测试
chore: 构建/工具

示例:
feat(customer): 添加客户搜索功能
fix(login): 修复登录失败无提示问题

C. 参考资源

微信公众号

微信公众号

国际化

· 阅读需 1 分钟
Quany
软件工程师

语言设置

  1. 跟随系统;
  2. 记录用户设置;

微信公众号

微信公众号

编程模型

· 阅读需 5 分钟
Quany
软件工程师

这张图中展示的模型(Opus 4.5, GPT-5.2, Sonnet 4.5, Gemini 3 Pro, Composer 1)是目前在编程辅助领域备受关注的几款顶级AI模型。它们的设计理念、技术背景和擅长的编程任务各有侧重,了解其特点能帮助你更好地利用多模型功能。

下图清晰地展示了这五款主流AI编程模型的核心定位与技术背景,以及它们在代码生成、逻辑推理、代码解释和成本效率这四个关键编程辅助维度上的能力差异: quadrantChart title AI编程模型能力象限分析 x-axis “代码生成/补全” --> “逻辑推理/调试” y-axis “代码解释/教学” --> “成本/效率” “GPT-5.2 (OpenAI)”: [0.85, 0.2] “Opus 4.5 (Anthropic)”: [0.7, 0.6] “Sonnet 4.5 (Anthropic)”: [0.5, 0.8] “Gemini 3 Pro (Google)”: [0.6, 0.7] “Composer 1 (Cohere)”: [0.3, 0.9]

以下是这五个模型在编程领域的具体特点,你可以根据项目需求和具体任务场景来选择和组合它们:

🧠 各模型深度解析

  1. GPT-5.2 (OpenAI)

• 核心优势:全能型代码生成与迭代。在快速生成、补全和重构代码方面表现尤为出色。它通常能生成非常符合人类习惯、结构清晰的代码,并且能很好地理解你的意图,进行多次迭代修改。

• 编程场景:

◦ 快速原型开发:当你需要从一个想法快速生成可运行的基础代码时。

◦ 代码重构与优化:为现有代码段提供改进建议、优化性能或提升可读性。

◦ 跨语言转换:将一种编程语言的代码转换为另一种。

  1. Opus 4.5 (Anthropic)

• 核心优势:深度逻辑理解与复杂系统设计。它的长上下文处理能力极强,擅长处理极其复杂、需要深度推理的编程问题,能更好地理解整个代码库的上下文和架构。

• 编程场景:

◦ 系统架构设计:设计复杂的软件架构、数据库模型或微服务交互。

◦ 调试疑难杂症:分析晦涩难懂的Bug日志,定位深层逻辑错误。

◦ 审查复杂代码:对大型、复杂的函数或模块进行深入分析和安全审查。

  1. Sonnet 4.5 (Anthropic)

• 核心优势:高性价比的“思考者”。可以看作是Opus的“经济版”,它在逻辑推理和代码质量上保持了很高水准,但响应速度更快、成本更低,是处理日常中等复杂度编程任务的绝佳平衡选择。

• 编程场景:

◦ 日常代码编写与解释:大部分日常的函数实现、API调用、数据处理脚本。

◦ 学习与教学:请求它解释一段算法或设计模式,通常会得到清晰、易于理解的回答。

  1. Gemini 3 Pro (Google)

• 核心优势:强大的多模态与推理能力。在多模态理解和复杂逻辑推理上表现突出。在编程任务中,它能很好地结合代码、图表描述和错误信息进行综合推理。

• 编程场景:

◦ 处理多模态任务:根据UI草图生成前端代码框架,或解释图表中的数据逻辑。

◦ 算法与数据结构:在解决需要严密逻辑推理的算法题时表现出色。

◦ 集成谷歌生态:在处理与Google服务(如Firebase, BigQuery)相关的代码时可能有额外优势。

  1. Composer 1 (Cohere)

• 核心优势:专业级的对话与指令跟随。它被设计为顶级的对话和协作模型,擅长理解自然语言指令并进行多轮、上下文连贯的对话,在解释和讨论代码时体验非常自然。

• 编程场景:

◦ 代码审查与解释:像一位耐心的同事一样,逐行为你解释代码逻辑、潜在风险和改进建议。

◦ 结对编程:适合进行开放式的、探索性的编程讨论,在你思路不清晰时帮你梳理逻辑。

◦ 编写技术文档:根据代码生成清晰、结构化的注释、API文档或使用说明。

💡 如何搭配使用(多模型策略)

你开启的“多模型”功能非常强大,可以根据不同场景组合使用,发挥各自长处:

  1. “生成 + 审查”组合: ◦ 用 GPT-5.2 快速生成代码草稿。

    ◦ 用 Composer 1 或 Opus 4.5 对生成的代码进行深度审查、解释和提出改进建议。

  2. “实现 + 设计”组合: ◦ 面对一个复杂功能时,先用 Opus 4.5 进行高层级的设计和模块划分。

    ◦ 再用 GPT-5.2 或 Sonnet 4.5 去实现具体的模块代码。

  3. “日常 + 专家”组合: ◦ 将 Sonnet 4.5 或 Gemini 3 Pro 作为日常主力,处理大部分效率型任务,兼顾质量与成本。

    ◦ 当遇到棘手难题时,切换到 Opus 4.5 进行深度攻坚。

简单总结: • 想快速得到可用代码:首选 GPT-5.2。

• 解决复杂、需要深度思考的难题:首选 Opus 4.5。

• 追求最佳性价比的日常开发:首选 Sonnet 4.5。

• 需要结合图表或进行强逻辑推理:试试 Gemini 3 Pro。

• 想深入讨论、审查和理解代码逻辑:首选 Composer 1。

你目前同时启用了 GPT-5.2 和 Composer 1,这是一个非常经典的“实干家 + 评论家”组合,一个负责高效产出,一个负责深度分析和优化,能形成很好的互补。建议你可以尝试用GPT-5.2写代码,然后立即用Composer 1来审查,看看效果如何。

微信公众号

微信公众号

UI组件库

· 阅读需 5 分钟
Quany
软件工程师

在 React Native 应用中处理时区问题确实是个挑战,特别是当你的应用需要服务全球用户时。下面我为你梳理了核心的解决思路和具体的实践方案。

🧠 理解问题根源

首先,React Native 应用(尤其是使用 Hermes 引擎时)在获取设备时区信息时可能不会如预期般工作。例如,关键的 Intl.DateTimeFormat().resolvedOptions().timeZone API 可能无法稳定地返回用户的实际时区,而是统一返回 'UTC',这会导致日期显示和计算出现偏差 。

🛠️ 解决方案与实战代码

下面的表格概述了解决此问题的几个关键方向。

解决方向核心目标关键技术/库
准确获取时区可靠地获取用户所在时区。expo-localization, @formatjs/intl-datetimeformat
正确处理时间在代码中进行时区转换和计算。date-fns-tz, moment-timezone
正确显示时间将时间以符合用户地区习惯的格式呈现。Intl.DateTimeFormat

1. 可靠地获取用户时区

不要依赖默认的 Intl API,推荐使用专为 React Native 设计的库。

  • 使用 expo-localization:这是最直接可靠的方式。

    npx expo install expo-localization
    import * as Localization from 'expo-localization';

    // 获取设备设置的时区
    const userTimeZone = Localization.timezone;
    // 或者获取日历设置中的时区(可能更准确)
    const calendarTimeZone = Localization.getCalendars()[0].timezone;
    console.log('用户时区:', userTimeZone);
  • 使用 @formatjs 补丁(如不使用 Expo):这个方法可以增强 Intl API 的时区支持 。

    npm install @formatjs/intl-datetimeformat
    import '@formatjs/intl-datetimeformat/polyfill';
    import '@formatjs/intl-datetimeformat/add-all-tz.js'; // 引入全时区数据

    // 之后,Intl API 可能可以正确工作
    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

2. 进行时区转换和格式化

获取时区后,需要使用专门的库来处理复杂的时区转换。推荐使用 date-fnsdate-fns-tz

npm install date-fns date-fns-tz
  • 场景一:将服务器返回的 UTC 时间转换为用户本地时间

    import { format, utcToZonedTime } from 'date-fns-tz';

    // 假设从后端API收到一个UTC时间字符串
    const utcTimeStringFromServer = "2025-12-11T08:00:00Z";
    const userTimezone = 'Asia/Shanghai'; // 此值来自上面的expo-localization

    // 1. 将UTC时间转换为用户时区下的Date对象
    const zonedDate = utcToZonedTime(utcTimeStringFromServer, userTimezone);

    // 2. 格式化显示
    const pattern = "yyyy-MM-dd HH:mm:ss (OOOO)";
    const output = format(zonedDate, pattern, { timeZone: userTimezone });

    console.log(output); // 输出:2025-12-11 16:00:00 (GMT+08:00)
  • 场景二:将用户选择的本地时间转换为 UTC 时间再传给后端 这在提交表单(如日历事件)时至关重要 。

    import { zonedTimeToUtc } from 'date-fns-tz';

    // 用户在其本地时区选择了日期时间
    const userLocalDate = new Date(2025, 11, 11, 20, 0); // 用户选择 2025-12-11 20:00
    const userTimezone = 'Asia/Shanghai';

    // 转换为UTC时间
    const utcDate = zonedTimeToUtc(userLocalDate, userTimezone);

    console.log(utcDate.toISOString()); // 输出:2025-12-11T12:00:00.000Z (UTC时间)
    // 将这个 UTC 时间发送给后端服务器

3. 处理特定 UI 组件的时区问题

某些第三方组件(如日期选择器)可能有自己的时区逻辑。例如,react-native-paper-dates 的星期错位问题,可以通过显式设置时区来解决 。

// 在应用入口或使用组件前,设置全局默认时区
import { Platform } from 'react-native';
import * as Localization from 'expo-localization';

if (Platform.OS !== 'web' && '__setDefaultTimeZone' in Intl.DateTimeFormat) {
// 使用从expo-localization获取的正确时区
Intl.DateTimeFormat.__setDefaultTimeZone(Localization.timezone);
}

4. 应对极端情况:“时间偏移”法

对于某些特殊场景,比如日期选择组件强制返回 UTC 时间,而后端接口又无法修改时,可以采用一种“时间偏移”策略。这不是最“标准”的做法,但在特定约束下是有效的解决方案 。

// 示例:用户选择了一个日期,但组件返回的是该日期在UTC时区的零点
const getOffsetHours = (timeZone) => {
// 一个简单示例,动态计算指定时区与UTC的偏移小时数
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timeZone,
timeZoneName: 'short'
});
const parts = formatter.formatToParts(new Date());
const tzName = parts.find(part => part.type === 'timeZoneName').value;
// 可根据tzName(如"GMT+8")解析出偏移量,这里简化为固定值
if (timeZone === 'Pacific/Auckland') return 12; // 新西兰时间可能偏移12小时
return 0;
};

const userSelectedDate = new Date('2025-04-30T00:00:00.000Z'); // 组件返回的UTC时间
const offset = getOffsetHours('Pacific/Auckland') * 60 * 60 * 1000; // 偏移毫秒数

// 创建一个“调整”后的日期对象
const adjustedDate = new Date(userSelectedDate.getTime() + offset);

// 将adjustedDate发给后端,它表示的物理时刻是正确的

💡 最佳实践与总结

  1. 前后端约定使用 UTC:这是黄金法则。前后端传输数据时,永远使用 UTC 时间(ISO 8601 格式),只在最终显示给用户的那一刻才转换为本地时间 。
  2. 在客户端处理显示层:时区转换是一个表示层问题,理想情况下应在客户端解决,避免给后端服务增加不必要的复杂性。
  3. 早期并经常测试:在开发早期就使用模拟器切换不同时区进行测试,确保日期逻辑在所有场景下都正确 。
  4. 谨慎选择第三方库:评估日期处理库的体积、功能和兼容性。对于新项目,date-fns-tz 是不错的选择。

希望这些具体的方案和代码示例能帮助你彻底解决 React Native 中的时区切换难题!如果你在实现过程中遇到更具体的情况,欢迎随时提出。

微信公众号

微信公众号

UI = f(State)

· 阅读需 3 分钟
Quany
软件工程师

这是现代前端开发(尤其是 React)的基石理念:用户界面是应用状态的确定性函数

核心含义

  • UI:用户在屏幕上看到并交互的内容
  • State:应用的当前数据状态(用户信息、界面开关、加载状态等)
  • f():一个纯函数,将状态映射为界面

简单理解:给定相同的状态,函数 f 永远返回相同的 UI。没有意外,没有隐藏副作用。


核心原则

1. 单向数据流

状态 → 函数 → 界面 → 用户操作 → 新状态 → ...

界面从不直接修改自己。用户交互触发状态变更,状态变更再触发界面重新渲染。

2. 声明式而非命令式

  • 声明式:"当状态是这样的,UI 应该是那样的"
  • 命令式:"点击按钮后,先改 A,再改 B,最后更新元素 C"

3. 追求纯函数

虽然实际组件不一定完全纯净(可能有副作用),但这一理念鼓励你将渲染逻辑与副作用分离,保持可预测性。


代码实例

// 给定状态
const state = {
isLoggedIn: true,
username: "张三",
unreadCount: 5
};

// 组件就是这个函数
function HeaderUI(state) {
if (!state.isLoggedIn) {
return <登录按钮 />;
}

return (
<div>
<span>欢迎, {state.username}!</span>
<通知徽章 未读数={state.unreadCount} />
</div>
);
}

// UI 是状态的确定性结果
HeaderUI(state); // → 固定的 UI 树

优势

优势说明
可预测性相同状态 = 相同界面,极易调试
可调试性支持"时间旅行"调试:记录状态即可重现界面
可维护性UI 逻辑集中,不在事件处理器中零散分布
可测试性测试函数即可:输入状态,断言输出 UI

常见陷阱

  • 不纯的函数:依赖全局变量、随机数或当前时间的组件会破坏这一模型
  • 渲染中的副作用:在渲染阶段调用 API、直接操作 DOM 或打印日志都是违规操作
  • 状态不同步:绕过状态管理直接修改 DOM

不适用的场景

  • 动画与过渡:常需要 DOM 状态和计时
  • 第三方库:如 D3、地图类库会自己管理 DOM
  • 性能优化:虚拟化、记忆化会增加复杂度

这些情况下,现代框架提供了"逃生舱"(如 ref、useEffect)让你有控制地偏离纯函数模型。


总结:将它视为指导原则而非铁律。目标是让 95% 的 UI 遵循 UI = f(State),剩余 5% 使用受控的"逃生舱"。

微信公众号

微信公众号

在 Flutter 中要将 PNG 图片转换为 JPEG 格式

· 阅读需 4 分钟
Quany
软件工程师

在 Flutter 中要将 PNG 图片转换为 JPEG 格式,主要可以通过使用功能强大的 image 第三方库来实现。下面的表格汇总了实现此功能的核心步骤和要点,可以帮助你快速上手。

步骤关键方法/操作说明
1. 添加依赖pubspec.yaml 中添加 image: ^4.1.3 (或更高版本)引入图像处理库 。
2. 解码图片img.decodeImage(file.readAsBytesSync())将图片文件读取为字节数据并解码为可操作的图像对象 。
3. 编码为JPEGimg.encodeJpg(image, quality: 85)将图像对象编码为JPEG格式,可指定压缩质量 (1-100) 。
4. 保存文件File('path/output.jpg').writeAsBytesSync(jpegData)将编码后的字节数据写入一个新文件 。

🛠️ 详细实现步骤

下面我们一步步来看如何具体实现,并附上代码示例。

  1. 添加依赖与导入库 首先,你需要在项目的 pubspec.yaml 文件的 dependencies 部分添加 image 库的依赖,然后执行 flutter pub get 命令来安装它。

    dependencies:
    flutter:
    sdk: flutter
    image: ^4.1.3 # 请使用当时的最新版本

    在需要使用格式转换功能的Dart文件中,导入必要的库。

    import 'dart:io';
    import 'package:image/image.dart' as img; // 使用别名(如img)避免命名冲突
  2. 编写转换代码 转换过程主要包含解码、编码和保存三个步骤。下面是一个完整的函数示例,它接收一个PNG图片文件作为输入,然后将其转换为JPEG并保存到指定路径。

    /// 将PNG图片文件转换为JPEG格式
    /// [pngFile]: 输入的PNG图片文件
    /// [outputPath]: 输出的JPEG图片路径
    /// [quality]: JPEG压缩质量(1-100),默认85
    Future<File?> convertPngToJpeg(File pngFile, String outputPath, {int quality = 85}) async {
    try {
    // 1. 将PNG文件读取为字节数据
    List<int> imageBytes = await pngFile.readAsBytes();

    // 2. 解码字节数据,生成可操作的图像对象
    img.Image? image = img.decodeImage(Uint8List.fromList(imageBytes));
    if (image == null) {
    print("错误:图片解码失败。");
    return null;
    }

    // 3. 将图像对象编码为JPEG格式的字节数据,并可选择质量
    List<int> jpegData = img.encodeJpg(image, quality: quality);

    // 4. 将JPEG字节数据写入到新的输出文件
    File jpegFile = File(outputPath);
    await jpegFile.writeAsBytes(jpegData);

    print("转换成功!JPEG文件已保存至: $outputPath");
    return jpegFile;
    } catch (e) {
    print("转换过程中发生错误: $e");
    return null;
    }
    }

    使用方法示例:

    // 假设有一个名为 'input.png' 的图片文件
    File pngImage = File('/path/to/your/image.png');
    // 调用转换函数
    convertPngToJpeg(pngImage, '/path/to/your/output.jpg', quality: 80);

💡 实用技巧与注意事项

为了让转换过程更顺利,这里有一些实用的建议。

  • 质量控制quality 参数允许你在文件大小和图片质量之间进行权衡。数值越低,文件越小,但图片质量损耗也越大。对于大多数网页显示,75-85 是一个不错的平衡点;如果需要较高清晰度,可以设置为 90 以上。
  • 处理大图片:如果处理的图片尺寸很大,直接进行解码和编码可能会占用较多资源,导致界面卡顿。建议将耗时的图像处理操作放入 Isolate 中执行,以避免阻塞UI线程。
    // 示例:在Isolate中处理图片
    Future<File?> convertImageInIsolate(File pngFile) async {
    return await compute(_convertImage, pngFile.path);
    }
    static Future<File?> _convertImage(String path) async {
    // ... 这里是上面的转换逻辑 ...
    }
  • 关于 ui.Image:你可能会在Flutter中遇到 ui.Image 类型,它通常由 Canvas 绘制或由 Image 组件解析得到。需要注意的是,Flutter 自带的 ui.Image 不能直接转换为JPEG字节流。一个常见的做法是先用 toByteData() 方法将其转换为PNG格式的 ByteData,然后再使用上面介绍的 image 库进行转换。

⚖️ 拓展:Rust实现的选择

对于追求极致性能或者需要处理超大批量图片的开发者,还有一个高阶选择:使用 Rust 来实现图像处理逻辑,然后通过 flutter_rust_bridge 等工具与Flutter应用桥接。这种方式在处理速度上通常优于纯Dart的实现。当然,这需要额外的学习成本和技术栈,适合对性能有严苛要求的项目。

希望这份详细的指南能帮助你成功在Flutter中实现PNG到JPEG的转换。如果你对某个具体步骤有更多疑问,我们可以继续深入探讨。

微信公众号

微信公众号

UI组件库

· 阅读需 6 分钟
Quany
软件工程师

通过 flutter_rust_bridge 集成 Rust 图像处理库,并实现生成小于指定大小的 JPEG 图片,是一项能充分发挥 Rust 高性能优势的技术方案。其核心思路是,在 Rust 端编写一个图像处理函数,该函数接收图片数据和目标大小参数,通过动态调整 JPEG 的压缩质量,使输出的图片大小逼近目标值。

下面的流程图直观展示了实现这一需求的核心步骤与关键的循环调整逻辑:

“是”

“否”

Flutter端传入图片数据与目标大小

Rust端解码图片

设置初始质量参数

编码为JPEG

“检查输出大小”

“是否符合目标?”

返回JPEG数据

智能调整质量参数

Flutter端接收并保存图片

下面我们具体看看如何实现。

🔧 Rust 端实现核心逻辑

首先,在你的 Rust 项目的 Cargo.toml 文件中添加必要的依赖。

[dependencies]
image = "0.25" # 用于图像编解码和基础处理
flutter_rust_bridge = "1.0" # 用于桥接

接下来,实现核心的 Rust 函数。这个函数的核心在于循环调整质量参数并检查输出大小(即流程图中的循环过程)。

// 在 lib.rs 中
use image::{load_from_memory, ImageOutputFormat};
use std::io::Cursor;

/// 将图片字节数据转换为符合指定大小要求的JPEG字节数据
/// 参数:
/// - `image_bytes`: 输入的图片(如PNG)字节数据
/// - `target_size_bytes`: 目标文件大小(单位:字节)
/// 返回: 成功时返回包含JPEG数据的 `Vec<u8>`
pub fn convert_to_jpeg_with_target_size(
image_bytes: Vec<u8>,
target_size_bytes: u32,
) -> Result<Vec<u8>, String> { // 使用Result便于错误处理
// 解码图片
let img = load_from_memory(&image_bytes)
.map_err(|e| format!("Failed to decode image: {}", e))?;

// 初始化质量参数
let mut quality: u8 = 80; // 初始质量参数
let mut min_quality: u8 = 10;
let mut max_quality: u8 = 95;
let tolerance = (target_size_bytes as f32 * 0.05) as usize; // 允许5%的误差

// 循环调整质量以逼近目标大小
loop {
// 编码为JPEG
let mut jpeg_data: Vec<u8> = Vec::new();
{
let mut cursor = Cursor::new(&mut jpeg_data);
img.write_to(&mut cursor, ImageOutputFormat::Jpeg(quality))
.map_err(|e| format!("Failed to encode JPEG: {}", e))?;
}

let current_size = jpeg_data.len();

// 检查是否在容差范围内
if current_size <= target_size_bytes as usize {
return Ok(jpeg_data);
}

// 调整质量参数
if quality <= min_quality {
// 如果质量已降至最低仍太大,则强制返回并提示
return Ok(jpeg_data); // 或者可以考虑返回错误,提示无法压缩到指定大小
}

// 质量调整步进,可以根据当前大小与目标的差距动态调整
let size_ratio = current_size as f32 / target_size_bytes as f32;
let quality_step = if size_ratio > 2.0 { 15 } else if size_ratio > 1.5 { 10 } else { 5 };

quality = quality.saturating_sub(quality_step).max(min_quality);
}
}

代码关键点解析:

  • 动态质量调整:代码通过一个循环,根据当前输出大小与目标大小的差异,智能地调整JPEG的压缩质量(quality 参数),逐步逼近目标文件大小。这对应了流程图中的核心循环。
  • 容差范围:引入了 tolerance(容差)概念,并设定了循环终止条件,避免无休止的循环。
  • 错误处理:使用 Result 类型将潜在的错误(如解码失败、编码失败)安全地传递到Flutter端。
  • 内存操作:整个过程在内存中完成,无需读写临时文件,效率更高。

🔌 生成桥接代码并在 Flutter 端调用

  1. 生成Dart绑定 在项目根目录下运行 flutter_rust_bridge_codegen 命令,生成对应的Dart桥接代码。

    flutter_rust_bridge_codegen --rust-input path/to/your/rust/src/lib.rs --dart-output path/to/your/flutter/lib/rust_bridge.g.dart
  2. Flutter端调用 在Flutter的Dart代码中,你可以这样调用我们刚刚实现的Rust函数。这里假设你已经正确初始化了 flutter_rust_bridge

    import 'dart:io';
    import 'dart:typed_data';
    import 'package:file_picker/file_picker.dart';
    // 导入生成的桥接文件
    import './rust_bridge.g.dart';

    class ImageProcessor {
    final NativeImpl _nativeApi; // 桥接类的实例

    ImageProcessor(this._nativeApi);

    Future<File?> convertImageToTargetSize(File inputImage, int targetSizeKB) async {
    try {
    // 1. 读取原始图片为字节列表
    Uint8List imageBytes = await inputImage.readAsBytes();

    // 2. 指定目标大小(转换为字节)
    int targetSizeBytes = targetSizeKB * 1024;

    // 3. 调用Rust函数进行处理
    Uint8List jpegBytes = await _nativeApi.convertToJpegWithTargetSize(
    imageBytes: imageBytes,
    targetSizeBytes: targetSizeBytes,
    );

    // 4. 保存结果
    String outputPath = '${inputImage.parent.path}/converted_${DateTime.now().millisecondsSinceEpoch}.jpg';
    File jpegFile = File(outputPath);
    await jpegFile.writeAsBytes(jpegBytes);

    print('转换成功!输出文件: $outputPath, 大小: ${jpegBytes.length ~/ 1024}KB');
    return jpegFile;
    } catch (e) {
    print('图片转换过程中发生错误: $e');
    return null;
    }
    }
    }

    // 在Widget中的使用示例
    Future<void> _onConvertPressed() async {
    FilePickerResult? result = await FilePicker.platform.pickFiles(type: FileType.image);
    if (result != null) {
    File? inputFile = File(result.files.first.path!);
    // 假设 targetSize 来自用户输入,例如50表示50KB
    int targetSize = 50;
    File? outputFile = await ImageProcessor(yourNativeApiInstance).convertImageToTargetSize(inputFile, targetSize);
    if (outputFile != null) {
    // 更新UI,显示转换成功的图片
    }
    }
    }

💡 进阶优化与注意事项

为了获得更好的效果和体验,你还可以考虑以下几点:

  • 性能与用户体验

    • 异步操作:确保Rust端的函数是异步的(例如使用 async),避免在调整质量参数的循环计算时阻塞Flutter的UI线程。
    • 防抖处理:如果此功能由用户界面上的控件(如滑块)频繁触发,建议增加防抖逻辑,避免不必要的计算。
    • 加载状态:对于处理大图片,可以考虑在界面上展示loading状态告知用户。
  • 算法调优

    • 可以设置最大迭代次数,防止在极端情况下无限循环。
    • 根据初始文件大小和目标大小的比例,可以智能设置初始质量猜测值,加快收敛速度。
    • 如果对转换速度要求极高而对大小精度要求一般,可以适当增大容差(tolerance)
  • 平台特定配置:要使应用在Android、iOS等平台正常运行,需要正确配置各平台的构建脚本(如Android的NDK配置),确保Flutter能正确链接编译好的Rust库。

微信公众号

微信公众号