この記事について
ユーザがアプリを一定時間操作していない場合に画面をロックし、PINコードや指紋・顔認証を求めるような機能をReactで実装してみます。
React Native + Expo でアプリを開発しているとして、認証機能自体はExpoのモジュール(Local Authentication)を使用します。
また、「ユーザが操作している」状態の定義は場合によるかと思いますが、ここでは画面内のどこかしらにタッチイベントが発生したかどうかで判定するとします。
これをマウスイベントに置き換えるなどすれば、大枠の実装はWebなど他のコンテクストでも共通するものになるはずです。つまりReact Nativeに関してそれほど専門性のある記事ではなく、Reactの学習者に向けた内容になっています。
実装の流れ
1. インターフェースを考える
まず、この機能をコンポーネント化するとしたら、どのようなインタフェースにすればよいでしょう。要件からすれば、認証するまでアプリの機能を使用できなくする(ロックする)という以上、アプリの他の機能(ベースとなる機能)の存在を前提としています。
また、ロック画面を一番手前(z-index最大)に表示してアプリ全体を覆ってしまえば実質的に他の機能は使用できなくなりますが、場合によっては機能全体、あるいは一部分を非表示にしたりする、あるいは一部の処理を無効にするなどの必要が出てくることが想定できます。つまりベースとなる機能自体に影響を与えるポテンシャルを持っている必要がある。そのためには、アプリの比較的根本の部分で、ベースとなる機能を包括(ラップ)するようなコンポーネントになるはずです。
コンポーネントの名前を仮に LockScreenProvider
とし、JSXで表記するとしたらこのようなイメージです。
<LockScreenProvider>
<App /> /* ベースとなる機能をchildrenとして渡す */
</LockScreenProvider>
TypeScriptのinterface
としてコンポーネントのpropsを定義するとしたら以下のようになります。
ロック画面を表示するまでの時間を定義しつつ、propとして任意に渡せるようにもしてみます。
interface Props {
children: React.ReactElement;
timeout?: number; // タイムアウト時間(最後に操作してからロック画面を表示するまでの時間,ms)
}
/**
* デフォルトのタイムアウト時間(ms)
*/
const DEFAULT_TIMEOUT = 30000;
さて、先ほど他の機能の「一部分を非表示にしたりする」と書きました。そもそもロック画面を不透明にしアプリを覆ってしまえば実質的にすべての他の機能が非表示になるため、必要となる場面は限られるかもしれませんが、もしこのような処理を行うためにはどうすればよいでしょうか。
一つの方法としては、子要素としてReact要素ではなく「React要素を返す関数」をとり、ロック状態を引数として渡すようなインターフェースが考えられます。
<LockScreenProvider>
{isLocked => <App isLocked={isLocked} />}
</LockScreenProvider>
interface Props {
children: (isLocked: boolean) => React.ReactElement;
timeout?: number;
}
子要素(例ではApp
)はこのisLocked
の値によって表示や処理を変えればよいわけです。
コンポーネント単体で機能が完結するため使い勝手は良いですが、この記事ではReactのContext / Provider APIを使ったもう一つのパターンを採用し、以下のように実装してみます。
Contextを作成し、そのContextを使用するためのフックを先ほどのLockScreenProviderのサブ機能としてexportしておきます。
/**
* useIsLocked
* 下階層でロック状態を参照できるようにContext化する
*/
const LockScreenContext = React.createContext<boolean>(false);
export function useIsLocked() {
return useContext(LockScreenContext);
}
/* 省略 */
const isLocked = true; // ロック状態を任意のロジックで管理する
return (
<LockScreenContext.Provider value={isLocked}>{/* ProviderとしてContextの値をセットする */}
{/* 省略(ここにロック画面などが入る) */}
{children}{/* 子要素をそのまま返す */}
</LockScreenContext.Provider>
);
下の階層でこのロック状態を取得するには、この useIsLocked
を使用します。
先ほどの子要素を関数としてとるパターンの場合は孫階層でロック状態を知ろうとするとpropsを順々に経由しなければなりませんが、Contextを使用することで、そのContextのProvider以下の階層であればどこでも直接的に値を参照できるようになります。
最初の例と同じく通常のReact要素として子要素を包みます。
<LockScreenProvider>
<App />
</LockScreenProvider>
下の階層(App
の子孫要素)では、どこでも先ほどの useIsLocked
を使用しロック状態を取得することができます。
import { useIsLocked } from "./LockScreenProvider.tsx";
/* 省略 */
const isLocked = useIsLocked(); // ロック状態を取得する
2. ロック状態をコントロールする処理
では、実際に「アプリを一定時間操作していない」ときにロック画面にする処理を実装していきます。
まずはロック状態をstateとして管理しましょう。
この値をロック画面の表示・非表示の制御に使用し、Contextの値としてコンポーネント配下に渡すことになります。
// ロック状態
const [isLocked, setIsLocked] = useState<boolean>(false);
一般的にJavaScriptにおいて、最後に何かをしてから一定時間経ったら、といった処理をする場合にはsetTimeout
およびclearTimeout
を使用します。
より確実に、厳密な時間を見る必要がある場合はrequestAnimationFrame
で(およそ60FPSの)毎フレーム時刻を見て差分を測る方法を採りますが、ここではその用途からしてそこまでの精度は求めないということにします。
まずは、後に clearTimeout
でタイマーを破棄するための識別子として、 setTimeout
の返り値を保持できるようにしておきます。
型はブラウザではnumber
ですが、ReactNativeの開発環境なのでNodeJS.Timeout
としています。
一般的に、(Class Componentは例外として)コンポーネント内で値を保持するためには先ほどと同じように useState
を使うので、素直にそうしておきましょう。この部分は後々修正します。
// タイマーの返り値を保持する
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
「一定時間〜しなかった時に」の部分の処理を実装します。
以下のように、「最後に何かをする」というトリガーになる処理の際にclearTimeout
およびsetTimeout
を使用し、タイマーによる処理を破棄、再予約する形になります。
// ロックする関数
const lock = useCallback(() => setIsLocked(true), []);
// タイマー処理を更新(破棄・再予約)する
const updateTimer = useCallback(() => {
if (isLocked) {
return;
}
// 既存のタイマーをクリア
clearTimeout(timer);
// タイマーを再予約
setTimer(setTimeout(lock, timeout));
}, [isLocked, timer, lock, timeout]);
// ロックを解除する関数
const unlock = useCallback(() => {
setIsLocked(false);
updateTimer(); // 再度タイマーをセット
}, [updateTimer]);
lock
、 updateTimer
、unlock
という関数を用意し、それぞれuseCallback
によってメモ化しています。
lock
は単純にロック状態に変更(isLocked
をtrue
に)する処理、
updateTimer
は何かしら操作が行われた際に呼ばれタイマーを更新する処理、
unlock
はロック状態を解除(isLocked
をfalse
に)し、タイマーをまたセットする処理です。
メモ化しているので、第二引数に依存する変数をいれることによってきっちりと各関数自体の更新がかかるようにします(また、その依存関係によってこの定義順になっています)。
ちなみに、このままではコンポーネントが最初にマウントされた際にタイマーがセットされません。
要件次第ですが、最初から何も操作していない場合にも一定時間後にロックする場合、以下のように useCallback
を使用し、マウント時にタイマーをセットする処理を追加します。
// 最初にタイマーをセット
useEffect(() => { // 第二引数に空の配列をいれているため、マウント時に呼ばれる
setTimer(setTimeout(lock, timeout));
}, []);
そして、肝心のイベントハンドリング、およびロック画面の定義を行います。
View
, Modal
, Text
などはReact Nativeの要素です。スタイリングなども適宜読みかえてください。
タッチイベントが発生した時に先ほどの updateTimer
を呼び、タイマー処理によってセットされたisLocked
フラグを使ってロック画面(例ではModal
部分)の表示を切り替えます。
ロック画面には、解除(unlock
)するボタンも入れておきます。このボタンにはこの記事の第二のテーマとして後ほど認証機能をつけましょう。
return (
<LockScreenContext.Provider value={isLocked}>
<View
style={styles.wrapper}
onTouchStart={updateTimer}
onTouchMove={updateTimer}
onTouchEnd={updateTimer}
>
<Modal visible={isLocked} transparent>
<View style={styles.modal}>
<Text style={styles.title}>
しばらく操作されていないため{"\n"}
画面がロックされています
</Text>
<TouchableOpacity onPress={unlock} style={styles.button}>
<Text style={styles.buttonTitle}>ロックを解除する</Text>
</TouchableOpacity>
</View>
</Modal>
{children}{/* 子要素をそのまま */}
</View>
</LockScreenContext.Provider>
);
この時点でアプリを起動して確認してみます。
適当な子要素を作り表示しています。
タッチ操作を行い、一定時間(先ほどデフォルトの時間を30秒としました)そのまま放置すると、
ロック画面が表示されました。
「ロックを解除する」ボタンで解除し、また放置すると、再びロック画面になり、
子要素で useIsLocked
を使用しロック状態を取得できているのも確認できました。
これで概ね処理の流れができました。
ところで、このコンポーネントにはこの時点でひとつ問題点があります。
useState
で保持している setTimeout
の返り値。これはタッチ操作の度に書き換えられます。
試しに timer
をコンソールに出してみると...
指でなぞるタッチ操作により連続で値が書き換わり、コンポーネントの関数自体が再評価されているのがわかります。
この値を画面に表示する必要があるような場合にはしょうがないので、適切にこのコンポーネント配下のレンダリングをチューニングする他ありませんが、この timer
の値は clearTimeout
のために保持しているにすぎず、画面表示には何の必要もありません。パフォーマンスの観点から好ましくないことは明白です。
では、この問題点を解決するにはどのような方法があるでしょうか。
ひとつには、あまり推奨されるべきではありませんが、この変数 timer
をこの関数コンポーネントのスコープ外に定義してしまう方法があります。このコンポーネントをシングルトン(実行時単一のインスタンスしか存在しないクラス)として使用すると決めているのであれば有効な手段です。
この場合、timer
を更新したとしても関数は再評価されません。
/* 省略 */
let timer: NodeJS.Timeout | null = null;
/**
* LockScreenProvider
* [Context Provider]
*/
function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> {
/* 省略 */
// タイマー処理を更新(破棄・再予約)する
const updateTimer = useCallback(() => {
if (isLocked) {
return;
}
// 既存のタイマーをクリア
clearTimeout(timer);
// タイマーを再予約
timer = setTimeout(lock, timeout));
}, [isLocked, lock, timeout]);
/* 省略 */
もう一つ、あまり良くない例を挙げます。useState
を使いつつ以下のようにすればどうでしょうか?
const [timerObj] = useState<{ timer: NodeJS.Timeout | null }>({ timer: null });
// タイマー処理を更新(破棄・再予約)する
const updateTimer = useCallback(() => {
if (isLocked) {
return;
}
// 既存のタイマーをクリア
clearTimeout(timerObj.timer);
// タイマーを再予約
timerObj.timer = setTimeout(lock, timeout);
}, [isLocked, timerObj, lock, timeout]);
set
関数を使わず、timerObj
自体が参照しているオブジェクト自体は同じであり、その中身(プロパティ)を書き換えているだけなので、関数コンポーネントの再評価は行われません。
ここでstateとして扱っている timerObj
のように、中身だけを変化することができるオブジェクトのことをミュータブル(mutable)なオブジェクトと呼びます。反対に、中身を変化させることはできず、変化させるためには新しく作成したものに置き換えなければいけないような制約を持つオブジェクトをイミュータブル(immutable)なオブジェクトと呼びます。
上記の実装では、set
関数を使用せずにstateを書き換えている点や、stateとして扱うデータをミュータブルなものとして扱っている点が、Reactの作法からは外れてしまっています。
ではどうすればよいかというと、構造的にはほとんど上記の例と変わりはないのですが...、
このような場合、つまりReactの作法に則った上でコンポーネント内部においてミュータブルなオブジェクトを保持したい場合には、下記のようにuseRef
を使用します。
const timerRef = useRef<NodeJS.Timeout | null>(null);
// タイマー処理を更新(破棄・再予約)する
const updateTimer = useCallback(() => {
if (isLocked) {
return;
}
// 既存のタイマーをクリア
clearTimeout(timerRef.current);
// タイマーを再予約
timerRef.current = setTimeout(lock, timeout);
}, [isLocked, timerRef, lock, timeout]);
refというと子コンポーネントやDOM要素などを参照するためのものというイメージがあるかもしれませんが、フックAPI以降その本質はこのように「ミュータブルなオブジェクト」であるという点にあります。
上記の例のように、refオブジェクトの current
プロパティには任意のタイミングで値を上書きすることができます。
これにより、タッチイベントが発生するたびにタイマーの識別子を上書きしても関数コンポーネント自体の再評価は行われなくなります。
また、updateTimer
が置き換わるタイミングが変わったことから、unlock
関数と初期表示時のタイマーセット、ロック解除後のタイマー再セットのロジックをよりシンプルに書き換えることができます。
// ロックを解除する関数
const unlock = useCallback(() => setIsLocked(false), []);
// ロック解除時にタイマー処理を更新
// 条件からして初期表示時にも呼ばれる
useEffect(() => {
if (!isLocked) {
updateTimer();
}
}, [isLocked, updateTimer]);
ではここまでで、「アプリを一定時間操作しなかったときに」ロック画面を表示するロジックは完成しました。Reactを使用したアプリケーション開発においてコンテクストに関わらず共通するような部分は以上になります。
3. 認証機能を実装する
Expoのモジュールを使用して認証画面を実装してみます。
まずはパッケージをインストールしましょう。
$ expo install expo-local-authentication
import * as LocalAuthentication from "expo-local-authentication";
モジュールは認証画面を表示するメソッドに加えて以下のチェック用のメソッド3つから成ります。非常にシンプルで、パーミッションも必要ありません。
-
LocalAuthentication.hasHardwareAsync()
その端末において指紋認証あるいは顔認証のセンサーが使用できるかどうかを返します。
実際に指紋や顔データの登録がなくても、ハードウェア自体にセンサーがあればtrue
を返すようです。 -
LocalAuthentication.supportedAuthenticationTypesAsync()
その端末において指紋認証、顔認証、Iris(虹彩認証)の中で使用できるものを返します。
返り値は定数の配列になっています。 -
LocalAuthentication.isEnrolledAsync()
その端末に指紋認証、あるいは顔認証のデータが登録されているかどうかを返します。
認証画面の表示にはLocalAuthentication.authenticateAsync(options)
を使用します。
生体認証用のデータが無い場合は自動的にパスコード(PINコード)認証にフォールバックされ、さらにパスコードが登録されていない場合には返り値の中にエラーメッセージ(passcode_not_set
)が入ります。
上記のチェック用メソッドによって認証方法を切り分けてもよいですが、使用可能な認証方法が自動的に選択されるようになっているため、特別な対応が必要であるとしたら生体認証用データもパスコードも登録されていない場合にどうするかという点のみでしょう。
その場合はそもそもロック画面を表示しない、あるいは独自の認証方法を適用するといったことが考えられます(この記事では割愛します)。
では、これまでに作成した LockScreenProvider
に認証機能をつけてみます。
先ほどの unlock
関数に以下のように処理を追加します。
// センサーや生体認証データが無いために認証ができない場合の処理
const fallbackAuthentication = useCallback(() => {
setIsLocked(false); // 仮でそのままロック解除とする
}, []);
// ロック/ロック解除処理
const lock = useCallback(() => setIsLocked(true), []);
const unlock = useCallback(async () => {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) {
fallbackAuthentication();
return;
}
const { success, error } = await LocalAuthentication.authenticateAsync({
promptMessage: "ロック画面を解除します",
});
if (success) {
setIsLocked(false);
} else if (error !== "user_cancel") {
fallbackAuthentication();
}
}, [fallbackAuthentication]);
認証データがパスコードを含め何も無い場合にはフォールバックの処理を行うようにし、オプションとして promptMessage
にメッセージを渡します。(その他のオプションはこちら)
ロック画面で解除するボタンを押すと、
Android 指紋認証(PIN画面はセキュリティのためかスクショできず)
iOS パスコード
iOS Touch ID
このような認証画面が表示され、認証が成功するとロック画面が消えるようになりました。
Androidでは指紋認証かPINコードかユーザが選択でき(それゆえに、オプションのdisableDeviceFallback
は効果がありません)、iOSではTouch IDの登録が無ければパスコード入力画面、あればTouch IDのダイアログが表示されます。
サンプルソース
以上でアプリを一定時間操作しなかったときに認証画面を表示する実装ができました。
この記事の実際のソースはこちらにまとめています。
https://github.com/mildsummer/expo-lock-screen-example
コンポーネントの最終的な全ソースはこちら。
/**
* LockScreenProvider
* 一定時間操作していないときにロック画面を表示する
*/
import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Modal, Text, ViewStyle, StyleSheet, View, TextStyle, TouchableOpacity } from "react-native";
import * as LocalAuthentication from "expo-local-authentication";
// props
// 型定義部
interface Props {
children: React.ReactElement;
timeout?: number;
}
// styles
// 型定義部
interface Styles {
modal: ViewStyle;
title: TextStyle;
button: ViewStyle;
buttonTitle: TextStyle;
wrapper: ViewStyle;
}
// スタイリング
const styles: Styles = StyleSheet.create<Styles>({
modal: {
flex: 1,
width: "100%",
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.8)"
},
title: {
marginBottom: 20,
fontSize: 20,
lineHeight: 20 * 1.4,
color: "#FFFFFF",
textAlign: "center",
},
button: {
padding: 12,
},
buttonTitle: {
fontSize: 20,
color: "#007aff",
},
wrapper: {
flex: 1,
width: "100%"
},
});
/**
* デフォルトのタイムアウト時間(ms)
*/
const DEFAULT_TIMEOUT = 3000;
/**
* useIsLocked
* 下階層でロック状態を参照できるようにContext化する
* (ロック状態のモーダルを半透明にしつつ、ロック状態ではユーザ状態を非表示にするなど)
* [Context]
*/
const LockScreenContext = React.createContext<boolean>(false);
export function useIsLocked() {
return useContext(LockScreenContext);
}
/**
* LockScreenProvider
* [Context Provider]
*/
function LockScreenProvider({ children, timeout = DEFAULT_TIMEOUT }): React.FunctionComponent<Props> {
// setTimeoutで返る値を保持
// (stateだとsetの際にrenderされてしまうがその必要が無いものはrefを使用)
const timerRef = useRef<NodeJS.Timeout | null>(null);
// ロック状態
const [isLocked, setIsLocked] = useState<boolean>(false);
// センサーや生体認証データが無いために認証ができない場合の処理
const fallbackAuthentication = useCallback(() => {
setIsLocked(false); // 仮でそのままロック解除する
}, []);
// ロック/ロック解除処理(memo化)
const lock = useCallback(() => setIsLocked(true), []);
const unlock = useCallback(async () => {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) {
fallbackAuthentication();
return;
}
const { success, error } = await LocalAuthentication.authenticateAsync({
promptMessage: "ロック画面を解除します",
});
if (success) {
setIsLocked(false);
} else if (error !== "user_cancel") {
fallbackAuthentication();
}
}, [fallbackAuthentication]);
// タイマー処理を更新する(memo化)
// タッチ操作時に連続で呼ばれる
const updateTimer = useCallback(() => {
if (isLocked) {
return;
}
// 既存のタイマーをクリア
clearTimeout(timerRef.current);
// タイマーを再セット
timerRef.current = setTimeout(lock, timeout);
}, [isLocked, timerRef, lock, timeout]);
// ロック解除時にタイマー処理を更新
// 条件からして初期表示時にも呼ばれる
useEffect(() => {
if (!isLocked) {
updateTimer();
}
}, [isLocked, updateTimer]);
return (
<LockScreenContext.Provider value={isLocked}>
<View
style={styles.wrapper}
onTouchStart={updateTimer}
onTouchMove={updateTimer}
onTouchEnd={updateTimer}
>
<Modal visible={isLocked} transparent>
<View style={styles.modal}>
<Text style={styles.title}>
しばらく操作されていないため{"\n"}
画面がロックされています
</Text>
<TouchableOpacity onPress={unlock} style={styles.button}>
<Text style={styles.buttonTitle}>ロックを解除する</Text>
</TouchableOpacity>
</View>
</Modal>
{children}
</View>
</LockScreenContext.Provider>
);
}
export default React.memo(LockScreenProvider);
追記(ロック状態の永続化)
このようなロジックでアプリをロックしたとしても、一度アプリを落として再起動するという方法で悪意のある操作をされてしまったら意味がありません。
そういった場合に対応するとしたら、以下のようにAsyncStorageと組み合わせてロック状態を永続化するといったことも必要になります。
他に永続化の仕組みを使っていれば必要ありませんが、簡単にAsyncStorageを使うユーティリティ的なフックを書いてみます。
import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
/**
* useStateのようなI/FでAsyncStorageを使用するhook
* @param {string} key Storageに保存するためのキー
* @param {string} initialValue 初期値
*/
export default function useStorage(key: string, initialValue: string): [string, Dispatch<SetStateAction<string>>] {
const keyRef = useRef<string>(key);
const [value, _set] = useState<string>(initialValue);
// 最初にAsyncStorageから取得
useEffect(() => {
AsyncStorage.getItem(key, (error, result) => {
if (result !== null) {
_set(result);
}
});
}, [key]);
// 値をセットする関数
// AsyncStorageにも反映
const set = useCallback((newValue: string) => {
_set(newValue);
AsyncStorage.setItem(key, newValue);
}, [key]);
// 念のため、keyが変わった場合に元のkeyのvalueを削除
useEffect(() => {
if (keyRef.current !== key) {
AsyncStorage.removeItem(key);
}
keyRef.current = key;
}, [keyRef, key]);
return [value, set];
}
string
型でしか保存できない点が面倒ですがこのような形でisLocked
を保存します。これにより、ロック画面になった状態でアプリを再起動すると即時ロック状態が引き継がれるようになりました。
const STORAGE_KEY = "APP_LOCKED";
/* 省略 */
// ロック状態
const [isLockedStr, setIsLockedStr] = useStorage(STORAGE_KEY, "false");
const setIsLocked = useCallback((isLocked) => {
setIsLockedStr(isLocked.toString());
}, []);
const isLocked = isLockedStr === "true";