2

React Native实战之安全

 2 years ago
source link: https://xieyufei.com/2022/08/28/React-Native-Safebox.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

React Native实战之安全密码箱

React Native实战之安_

2022年8月28日 下午

4.8k 字

68 分钟

  在学习了RN的样式、组件和路由之后,我们就可以来尝试自己开发一款我们自己的App,本文我们结合实际生活中的需要,从零开始实战开发一个App,开发期间也遇到了大大小小的坑,分享给大家希望帮助大家规避。

  我们在浏览网站或者App时经常会注册各种各样的账号,手机自带的的密码保险箱功能只能保存App的密码,不能保存网页的;不同网站的注册账号和密码的规则还不一样,因此我们的需求也很简单,记录下每个网站或App注册的账号密码即可;笔者之前在应用市场下载过某密码箱的App,如下:

某密码箱

  但是将重要的密码保存在别人的App上,尤其是涉及到自己隐私的密码,总觉得有些不放心,其实主要是这破App还开始收费了;因此我们可以利用之前学习的RN知识来开发一款自用的密码保险箱,既安全可靠又物美价廉,说干就干。

  我们首先来搭建项目:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

react-native init rnSafeBox

  这里RN的第一个小坑来了,RN的版本已经到了0.68以上,它强制使用JDK 11进行Android build;我们看下0.68版本最低要求:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Node >= 14版本 java jdk >= 11版本

  但笔者装的版本比较早,是JDK1.8,因此我们搭建项目时需要留意自己的JDK版本;我们可以加上--version来指定RN的版本

react-native init rnSafeBox --version 0.67.0

  搭建后,我们加入常用的一些依赖,如图标和路由导航,这里不再赘述了,需要的小伙伴可以看下这篇文章:深入学习React Native之路由导航。组件库的话,我们选择了[email protected],它的组件较为丰富且全面:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

yarn add native-base [email protected] [email protected]

  我们在项目下新建src目录作为我们代码的主要目录,然后建立以下结构:

|- src
|- router
|- index.js
|- utils
|- index.js
|- storage.js
|- views
|- Login
|- Home
|- Passwd
|- Change

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  router存放我们的路由组件,这里由于不需要复杂的选项卡导航,我们直接使用堆栈导航即可;我们首先改造入口的App.js,加入NativeBase和导航的Provider容器:

import {NativeBaseProvider} from 'native-base';
import {NavigationContainer} from '@react-navigation/native';
import Router from './src/router/index';

const App = () => {
return (
<SafeAreaProvider>
<NavigationContainer>
<NativeBaseProvider>
<Router></Router>
</NativeBaseProvider>
</NavigationContainer>
</SafeAreaProvider>
);
};

  给我们的路由router/index.js导入页面:

class Router extends Component {
render() {
return (
<Stack.Navigator>
<Stack.Screen
name="Login"
component={Login}
options={{headerShown: false}}
/>
<Stack.Screen
name="Home"
component={Home}
options={{title: '首页'}}
/>
<Stack.Screen
name="Passwd"
component={Passwd}
options={{title: '密码'}}
/>
<Stack.Screen
name="Change"
component={Change}
options={{title: '修改入口密码'}}
/>
</Stack.Navigator>
);
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  首先我们来看下登录页面,登录页面比较简单,我们只需要一个输入框和确定按钮(省略其他组件代码):

import {Sae} from 'react-native-textinput-effects';
class Login extends Component {
constructor(props) {
super(props);
this.state = {
now: '', // storage中的密码
pwd: '',// 用户输入的密码
};
}
clickSubmit() {}
render() {
const {pwd} = this.state;
return (
<Sae
value={pwd}
label={'入口密码'}
secureTextEntry={true}
iconClass={FontAwesomeIcon}
iconName={'lock'}
onChangeText={value => this.setState({pwd: value})}
/>
<View>
<Button onPress={() => this.clickSubmit()}>确定</Button>
</View>
)
}
}

  这里我们用到react-native-textinput-effects组件,这是一个用纯js实现不同的炫酷效果的textinput组件库,实现的输入框效果如下:

Sae效果

Sae效果

  其次是我们的首页,用来展示账号密码的列表:

class Home extends Component {
constructor() {
super();
this.state = {
list: [],
};
}
render() {
return (
<View style={styles.box}>
<ScrollView>
{list.map((item, index) => {
return <ListItem item={item} index={index}></ListItem>;
})}
</ScrollView>
<View style={styles.fixedAddBtn}>
<Icon name={"plus"} size={30} color="#fff" />
</View>
</View>
);
}
}

  fixedAddBtn按钮用来点击跳转到新增账号密码的页面;我们将list中的每个数据封装成ListItem组件,方便后面进行动画效果的展示。效果如下:

首页

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  然后是修改入口密码页面Change,它分为两种情况,如果已经设置过了,就进行修改;如果没有设置,则可以设置新的密码。

class Change extends Component {
render() {
const { showOldPwd } = this.state;
return (
<View>
{showOldPwd && (
<Sae
label={"请输入旧入口密码"}
onChangeText={(value) => this.setState({ oldPwd: value })}
/>
)}
<Sae
label={"新的入口密码"}
onChangeText={(value) => this.setState({ newPwd: value })}
/>
<Sae
label={"请再次输入密码"}
onChangeText={(value) => this.setState({ repeatPwd: value })}
/>
<Button>确定</Button>
</View>
);
}
}

修改入口密码

修改入口密码

  最后是新增和编辑账号密码页面Passwd,它的部分代码如下:

class Passwd extends Component {
showSheet() {
this.setState({
isVisible: true,
});
}
render() {
return (
<View style={styles.box}>
<TouchableWithoutFeedback onPress={() => this.showSheet()}>
<View>
<Text>密码类型</Text>
<Input isDisabled={true} value={pwdType} placeholder="请选择密码类型" />
</View>
</TouchableWithoutFeedback>
<View>
<Text>用户名</Text>
<Input
value={username}
placeholder="请输入用户名"
onChangeText={(value) => this.setState({ username: value })}
/>
</View>
<View>
<Text>密码</Text>
<Input
value={password}
placeholder="请输入密码"
onChangeText={(value) => this.setState({ password: value })}
/>
</View>
</View>
);
}
}

  这里除了密码类型,其他字段(用户名、密码、标题、网址)等都是输入框,直接输入即可;密码类型点击后呈现下拉框,我们这里使用native-base的Actionsheet组件:

<Actionsheet isOpen={isVisible} onClose={() => this.closeSheet()}>
<Actionsheet.Content>
{list.map((el, index) => {
return (
<Actionsheet.Item
key={index}
onPress={() => this.clickTypeItem(el, index)}
>
{el.name}
</Actionsheet.Item>
);
})}
</Actionsheet.Content>
</Actionsheet>;

  Actionsheet效果如下:

Passwd页面效果

Passwd页面效果

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们的页面框架已经基本搭建完成了,我们对路由组件进行一些改造,对登录状态进行区分;在登录成功后才能进入首页及后续页面,否则只能展示登录页面:

<Stack.Navigator>
{isLogin ? (
<>
<Stack.Screen name="Home" component={Home} options={{ title: "首页" }} />
{/* 以及其他页面 */}
</>
) : (
<Stack.Screen
name="Login"
component={Login}
options={{ headerShown: false }}
/>
)}
</Stack.Navigator>

  我们设置初始化页面为登录页,同时登录页不需要展示header;再给其他页面的header设置统一的背景颜色和字体颜色:

<Stack.Navigator
initialRouteName="Login"
screenOptions={{
headerStyle: {
backgroundColor: '#128574',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'normal',
},
}}>
</Stack.Navigator>

  现在我们需要通过isLogin变量来控制路由的变化,由于登录操作时在登录页面判断的,我们可以通过全局的event bus来进行监听,在页面销毁时不要忘记移除监听事件:

import bus from '../utils/bus.js';
class Router extends Component {
constructor(props) {
this.getLogin = this.getLogin.bind(this);
}
getLogin() {
this.setState({
isLogin: true,
});
}
componentDidMount() {
bus.addListener("login", this.getLogin);
}
componentWillUnmount() {
bus.removeListener("login", this.getLogin);
}
}

  bus.js的代码也很简单,直接调用events库:

import {EventEmitter} from 'events';
const eventBus = new EventEmitter();
export default eventBus;

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  数据的存和取是我们这个App的核心功能,我们用到Async Storage这个库,它是一个给RN进行数据存储的依赖库,首先进行安装:

npm install @react-native-async-storage/async-storage
# or
yarn add @react-native-async-storage/async-storage

  它的用法也很简单,和LocalStorage的API有点类似,都是以字符串形式存储键和值,我们把它封装到utils/storage.js,方便调用:

import AsyncStorage from '@react-native-async-storage/async-storage';
const getData = async (key = '') => {
try {
return await AsyncStorage.getItem(`@storage_${key}`);
} catch (e) {return ''; }
};
const storeData = async (key = '', value = '') => {
try {
await AsyncStorage.setItem(`@storage_${key}`, value);
} catch (e) {}
};

const storeObjectData = async (key, obj) => {
if (typeof obj !== 'object') {
console.warn('obj is not a object');
return;
}
try {
await AsyncStorage.setItem(`@storage_${key}`, JSON.stringify(obj));
} catch (e) {}
};
const getObjectData = async (key = '') => {
let res = await AsyncStorage.getItem(`@storage_${key}`);
if (res) {
try {
return JSON.parse(res);
} catch (error) {
return {};
}
} else {
return {};
}
};

  我们封装了两组APi,一组是直接存取值的,另一组是可以存取对象的。这样,我们在登录页时,先把存储的入口密码entrance取出来,如果不存在,则直接进入首页;如果存在,在下面点击登录按钮时就将用户输入的值进行比较即可:

import { getData } from "../../utils/storage";
class Login extends Component {
async componentDidMount() {
await this.getStorage();
}
async getStorage() {
let now = await getData("entrance");
if (typeof now === "string" && now) {
this.setState({ now });
} else {
// 没有设置入口密码,直接进入
bus.emit("login");
}
}
clickSubmit() {
const { pwd, now } = this.state;
if (!pwd) {
showToast("请输入入口密码");
return;
}
if (pwd !== now) {
showToast("入口密码不正确");
return;
}
// 登录成功
bus.emit("login");
}
}

  在设置入口密码的Change页面,我们对新旧密码进行一系列校验,然后直接保存到storage中即可,这里不再赘述了。

  在首页,我们在updateList函数中将storage中保存的每一条密码取出来放到list中进行展示,但是在新增或者编辑storage中的密码之后,需要及时重新调用这个函数更新list数组,我们在RN路由导航中的导航的生命周期中说过,可以监听focus事件来判断页面是否重新聚焦:

class Home extends Component {
// 更新list数组
async updateList() {}
async componentDidMount() {
await this.updateList();

this._focus = this.props.navigation.addListener("focus", async () => {
await this.updateList();
});
}
componentWillUnrmount() {
this._focus();
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们整个App的存储和读取的功能已经基本完成,基本功能也能够使用了,下面对界面和功能进行一些优化。

新增按钮加阴影

  在原版的App中,新增按钮有一个阴影的效果,阴影效果也是一个很常见的需求;在CSS3中,我们可以直接通过box-shadow属性实现,在RN中iOS平台支持以下shadow属性:

<View
style={{
shadowOffset: {
width: 10,
height: 10,
},
shadowOpacity: 0.8,
shadowRadius: 1.5,
}}
/>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  在安卓端可以通过elevation属性,但是两者表现形式差别很大;因此我们引入第三方的react-native-shadow,它是利用react-native-svg画出svg阴影,因此两端效果比较一致;我们同时安装这两个依赖:

yarn add react-native-shadow
yarn add react-native-svg

  然后给fixedAddBtn套一层BoxShadow组件:

import { BoxShadow } from "react-native-shadow";
<BoxShadow
setting={{
width: 30,
height: 30,
color: "#000",
border: 20,
radius: 15,
opacity: 0.1,
x: 0,
y: 25,
style: {
justifyContent: "center",
alignItems: "center",
flex: 1,
},
}}
>
<View style={styles.fixedAddBtn}>
<Icon name={"plus"} size={30} color="#fff" />
</View>
</BoxShadow>;

  我们点击复制按钮可以直接复制账号或者密码到剪切板;RN已经将Clipboard功能从核心代码中分离出来,我们需要安装一下第三方的模块:

yarn add @react-native-clipboard/clipboard

  调用Clipboard.setString,复制到剪切板,调用await Clipboard.getString()获取剪切板的内容;我们这边使用setString即可:

import Clipboard from "@react-native-clipboard/clipboard";
class Home extends Component {
clickCopy(text) {
Clipboard.setString(text);
}
render() {
return (
<view>
<Text>用户名:</Text>
<TouchableOpacity onPress={() => this.clickCopy(username)}>
<Text>{username}</Text>
</TouchableOpacity>
</view>
);
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

指纹识别登录

  现在的智能手机一般都带有指纹识别的传感器,我们可以利用指纹模块来方便用户登录;React Native Fingerprint Scanner是一个RN库,用于使用指纹对用户进行身份验证;它提供了一个默认视图,提示用户将手指放在传感器上进行扫描。

  我们根据安装教程安装该依赖后,发现它的文档看起来很多,很唬人,但是核心的API只有两个,首先是isSensorAvailable,用来判断传感器是否可用,如果手机没有设置指纹则不可用,该函数直接报错,因此通过try/catch进行包装:

class Login extends Component {
// 判断指纹传感器是否可用
async checkSensorsAvailable() {
let flag = false;
try {
let biometryType = await FingerprintScanner.isSensorAvailable();
flag = true;
} catch (error) {}
return flag;
}
}

  在安卓端biometryType值如果可用的话为Biometrics,iOS端为’Touch ID’和’Face ID’,目前我们只考虑安卓端;判断后我们就真正可以调用指纹模块了,authenticate函数自动唤起指纹识别模块的模态框,我们填入title和description一些描述文案来引导用户:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

class Login extends Component {
async scanAuth() {
let flag = false;
try {
let res = await FingerprintScanner.authenticate({
title: "指纹登录",
description: "请触摸指纹传感器",
cancelButton: "取消",
});
if (res === true) {
flag = true;
}
} catch (error) {}
return flag;
}
}

  我们在页面初始化时,如果设置了入口密码,则进行指纹识别校验,自动弹出模态框,验证成功后跳转首页,同时不要忘了在页面销毁时调用release释放指纹模块的资源:

class Login extends Component {
async componentDidMount() {
// 当前已经设置了密码
if (this.state.now) {
let available = await this.checkSensorsAvailable();
if (available) {
let auth = await this.scanAuth();
if (auth) {
bus.emit("login");
}
}
}
}
// 释放资源
componentWillUnmount = () => {
FingerprintScanner.release();
};
}

  指纹识别效果如下:

指纹识别

  放错图了,应该是下面这张:

指纹识别

箭头添加动效

  我们在首页的列表展开时,给小箭头一个旋转的动画效果;RN中提供了Animated API来实现动画,可以简洁的实现各种动画和交互方式,并且具备极高的性能,我们从RN中导出模块:

import { Animated } from "react-native";
class ListItem {
constructor() {
const rotateDeg = new Animated.Value(0);
this.state = {
rotateDeg: rotateDeg,
};
}
render() {
const { rotateDeg } = this.state;
const rotate = rotateDeg.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "180deg"],
});
return (
<View>
<Animated.View style={{ transform: [{ rotate: rotate }] }}>
<Icon name="up" size={20} color="#333" />
</Animated.View>
</View>
);
}
}

  我们首先使用new Animated.Value创建一个值,在render函数中使用了一个interpolate()插值函数,用于将输入值范围转换为输出值范围,这里是将[0, 1]输入转为[“0deg”, “180deg”]输出。

const ANIMATE_DURATION = 200;
class ListItem {
clickListItem(item, index) {
if (item.toggle) {
// 当前是打开状态
Animated.timing(this.state.rotateDeg, {
toValue: 0,
duration: ANIMATE_DURATION,
useNativeDriver: true,
}).start();
} else {
// 当前是关闭状态
Animated.timing(this.state.rotateDeg, {
toValue: 1,
duration: ANIMATE_DURATION,
useNativeDriver: true,
}).start();
}
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  当点击展开列表元素时,调用Animated.timing,使得值按照过渡曲线随时间变化而变化,duration设置动画的执行时间,最终效果如下:

箭头动画效果

箭头动画效果

打包及一些问题

  我们的App开发完成后,就需要进行打包了,这里简单的看下笔者遇到的一些坑,希望能够让大家在开发时规避类似的坑。

打包apk后运行闪退

  我们开发时没有问题,但打包成apk文件后,兴冲冲的安装,结果现实泼了一盆冷水,运行直接闪退;搜索一番后,说是index.android.bundle文件没正常生成所致,我们运行一下打包命令,首先进行js文件的打包,再运行./gradlew assembleRelease打包:

react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle  --assets-dest android/app/src/main/res/

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Duplicate resources

  打包时还会遇到Duplicate resources报错,翻译过来就是重复资源:

:app:mergeReleaseResources FAILED

Execution failed for task ':app:mergeReleaseResources'.

/android/app/build/generated/res/react/release/drawable-XXXX: Error: Duplicate resources

  网上说是打包问题,打开node_modules/react-native/react.gradle文件,在doFirst代码段后面新增以下代码:

doFirst{ ... }
# 新增
doLast {
def moveFunc = { resSuffix ->
File originalDir = file("${resourcesDir}/drawable-${resSuffix}")
if (originalDir.exists()) {
File destDir = file("$buildDir/../src/main/res/drawable-${resSuffix}")
ant.move(file: originalDir, tofile: destDir)
}
}
def moveRawFunc = { dir ->
File originalDir = file("${resourcesDir}/${dir}")
if (originalDir.exists()) {
File destDir = file("$buildDir/../src/main/res/${dir}")
ant.move(file: originalDir, tofile: destDir)
}
}

moveFunc.curry("ldpi").call()
moveFunc.curry("mdpi").call()
moveFunc.curry("hdpi").call()
moveFunc.curry("xhdpi").call()
moveFunc.curry("xxhdpi").call()
moveFunc.curry("xxxhdpi").call()
moveRawFunc.curry("raw").call()
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

Reanimated报错

  react-native-reanimated依赖报错:

error: node_modules/react-native-reanimated/src/index.ts: /Users/tomekzaw/RNOS/Issue3397/node_modules/react-native-reanimated/src/index.ts: Export namespace should be first transformed by `@babel/plugin-proposal-export-namespace-from`.
5 | export * from './reanimated1';
6 | export * from './reanimated2';
> 7 | export * as default from './Animated';
| ^^^^^^^^^^^^

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  打开babel配置文件babel.config.js,新增如下插件:

module.exports = {
presets: ['module:metro-react-native-babel-preset'],
+ plugins: ['react-native-reanimated/plugin'],
};

  参考:https://github.com/software-mansion/react-native-reanimated/issues/3410

FingerprintScanner

  react-native-fingerprint-scanner打包时报如下错误:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:checkDebugAarMetadata'.
> Could not resolve all files for configuration ':app:debugRuntimeClasspath'.
> Could not find com.wei.android.lib:fingerprintidentify:1.2.6.
Required by:
project :app > project :react-native-fingerprint-scanner

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  build.gradle新增jcenter()

  参考:https://github.com/hieuvp/react-native-fingerprint-scanner/issues/192

  我们的自研App到这里基本就结束了,该有的功能也都有了;当然在开发过程中也遇到了不少坑,好几个依赖包安装和运行中都出现了大大小小不同的问题,上面的bug列表只是列举了一些典型的问题;只要我们利用好Github和Stack Overflow,总能找到解决方案。

悠闲的

  有兴趣的小伙伴可以到Github给个Star,有好的想法和改进建议也欢迎提给我;本App只将数据保存在本地,没有上传服务器,因此大家可以放心使用;如果想要体验,请在公众号【前端壹读】后台回复关键词安全密码箱即可获取App的安装包。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK