mobile-developer
React NativeおよびExpoを用いたクロスプラットフォームモバイルアプリ開発を専門とするスキルです。iOSとAndroid両対応のアプリ構築や、Expoエコシステムを活用した効率的な開発フローの実現に役立ちます。
description の原文を見る
Expert in React Native, Expo, and cross-platform mobile development
SKILL.md 本文
モバイルデベロッパー スキル
React NativeとExpoを使ったクロスプラットフォームモバイルアプリの構築をお手伝いします。
提供内容
アプリ開発:
- React Native / Expoアプリ (iOS + Android)
- ナビゲーションとルーティング
- ステート管理
- API統合
ネイティブ機能:
- カメラ、位置情報、通知
- 生体認証
- ファイルシステムアクセス
- デバイスセンサー
パフォーマンス:
- バンドルサイズの最適化
- レイジーローディング
- 画像最適化
- メモリ管理
配布:
- App Store / Google Play提出
- Over-the-air (OTA) アップデート
- ベータテスト (TestFlight、内部テスト)
クイックスタート: Expoアプリ
新しいアプリを作成
# Expoアプリを作成
npx create-expo-app my-app --template blank-typescript
cd my-app
# 依存関係をインストール
npx expo install react-native-screens react-native-safe-area-context
npx expo install expo-router
# 開発を開始
npx expo start
プロジェクト構造
my-app/
├── app/
│ ├── (tabs)/
│ │ ├── index.tsx # ホームタブ
│ │ ├── profile.tsx # プロフィールタブ
│ │ └── _layout.tsx # タブレイアウト
│ ├── users/
│ │ └── [id].tsx # ダイナミックルート
│ ├── _layout.tsx # ルートレイアウト
│ └── +not-found.tsx # 404ページ
├── components/
│ ├── Button.tsx
│ ├── Card.tsx
│ └── Loading.tsx
├── hooks/
│ └── useAuth.ts
├── app.json
└── package.json
Expo Routerでのナビゲーション
タブナビゲーション
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: false
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
)
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
)
}}
/>
</Tabs>
)
}
スタックナビゲーション
// app/users/[id].tsx
import { useLocalSearchParams } from 'expo-router'
import { View, Text } from 'react-native'
export default function UserDetail() {
const { id } = useLocalSearchParams()
return (
<View>
<Text>User ID: {id}</Text>
</View>
)
}
UIコンポーネント
カスタムボタン
// components/Button.tsx
import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native'
interface ButtonProps {
title: string
onPress: () => void
variant?: 'primary' | 'secondary'
loading?: boolean
disabled?: boolean
}
export function Button({
title,
onPress,
variant = 'primary',
loading = false,
disabled = false
}: ButtonProps) {
return (
<TouchableOpacity
style={[
styles.button,
variant === 'primary' ? styles.primary : styles.secondary,
disabled && styles.disabled
]}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.text}>{title}</Text>
)}
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
button: {
padding: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center'
},
primary: {
backgroundColor: '#007AFF'
},
secondary: {
backgroundColor: '#8E8E93'
},
disabled: {
opacity: 0.5
},
text: {
color: '#fff',
fontSize: 16,
fontWeight: '600'
}
})
カードコンポーネント
// components/Card.tsx
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import { ReactNode } from 'react'
interface CardProps {
title?: string
children: ReactNode
onPress?: () => void
}
export function Card({ title, children, onPress }: CardProps) {
const Container = onPress ? TouchableOpacity : View
return (
<Container
style={styles.card}
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
>
{title && <Text style={styles.title}>{title}</Text>}
{children}
</Container>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12
}
})
データフェッチング
カスタムフック
// hooks/useQuery.ts
import { useState, useEffect } from 'react'
interface UseQueryResult<T> {
data: T | null
loading: boolean
error: Error | null
refetch: () => void
}
export function useQuery<T>(url: string): UseQueryResult<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchData = async () => {
try {
setLoading(true)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const json = await response.json()
setData(json)
setError(null)
} catch (e) {
setError(e as Error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [url])
return { data, loading, error, refetch: fetchData }
}
使用例
// app/(tabs)/index.tsx
import { View, Text, FlatList, RefreshControl } from 'react-native'
import { useQuery } from '@/hooks/useQuery'
import { Card } from '@/components/Card'
interface Post {
id: string
title: string
content: string
}
export default function HomeScreen() {
const { data, loading, error, refetch } = useQuery<Post[]>(
'https://api.example.com/posts'
)
if (error) {
return (
<View>
<Text>Error: {error.message}</Text>
</View>
)
}
return (
<FlatList
data={data || []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Card title={item.title}>
<Text>{item.content}</Text>
</Card>
)}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
/>
)
}
ネイティブ機能
カメラ
// app/camera.tsx
import { Camera, CameraType } from 'expo-camera'
import { useState } from 'react'
import { Button, View, StyleSheet } from 'react-native'
export default function CameraScreen() {
const [type, setType] = useState(CameraType.back)
const [permission, requestPermission] = Camera.useCameraPermissions()
if (!permission) {
return <View />
}
if (!permission.granted) {
return (
<View style={styles.container}>
<Button onPress={requestPermission} title="Grant Camera Permission" />
</View>
)
}
return (
<View style={styles.container}>
<Camera style={styles.camera} type={type}>
<View style={styles.buttonContainer}>
<Button
onPress={() => {
setType(current =>
current === CameraType.back
? CameraType.front
: CameraType.back
)
}}
title="Flip Camera"
/>
</View>
</Camera>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
camera: { flex: 1 },
buttonContainer: {
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'flex-end',
padding: 20
}
})
プッシュ通知
// hooks/useNotifications.ts
import { useState, useEffect, useRef } from 'react'
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
import { Platform } from 'react-native'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false
})
})
export function useNotifications() {
const [expoPushToken, setExpoPushToken] = useState('')
const notificationListener = useRef<Notifications.Subscription>()
const responseListener = useRef<Notifications.Subscription>()
useEffect(() => {
registerForPushNotificationsAsync().then(token => setExpoPushToken(token || ''))
notificationListener.current = Notifications.addNotificationReceivedListener(notification => {
console.log('Notification received:', notification)
})
responseListener.current = Notifications.addNotificationResponseReceivedListener(response => {
console.log('Notification clicked:', response)
})
return () => {
Notifications.removeNotificationSubscription(notificationListener.current!)
Notifications.removeNotificationSubscription(responseListener.current!)
}
}, [])
return { expoPushToken }
}
async function registerForPushNotificationsAsync() {
let token
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C'
})
}
if (Device.isDevice) {
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
alert('Failed to get push token for push notification!')
return
}
token = (await Notifications.getExpoPushTokenAsync()).data
} else {
alert('Must use physical device for Push Notifications')
}
return token
}
位置情報
// hooks/useLocation.ts
import { useState, useEffect } from 'react'
import * as Location from 'expo-location'
export function useLocation() {
const [location, setLocation] = useState<Location.LocationObject | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
setError('Permission to access location was denied')
return
}
const location = await Location.getCurrentPositionAsync({})
setLocation(location)
})()
}, [])
return { location, error }
}
ステート管理
Zustand (推奨)
// store/auth.ts
import { create } from 'zustand'
interface User {
id: string
email: string
name: string
}
interface AuthStore {
user: User | null
token: string | null
login: (email: string, password: string) => Promise<void>
logout: () => void
}
export const useAuthStore = create<AuthStore>(set => ({
user: null,
token: null,
login: async (email, password) => {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
const { user, token } = await response.json()
set({ user, token })
},
logout: () => {
set({ user: null, token: null })
}
}))
使用例:
// app/login.tsx
import { useState } from 'react'
import { View, TextInput } from 'react-native'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/Button'
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const login = useAuthStore(state => state.login)
return (
<View>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
/>
<TextInput
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button
title="Login"
onPress={() => login(email, password)}
/>
</View>
)
}
パフォーマンス最適化
画像最適化
// components/OptimizedImage.tsx
import { Image } from 'expo-image'
import { StyleSheet } from 'react-native'
interface OptimizedImageProps {
uri: string
width: number
height: number
}
export function OptimizedImage({ uri, width, height }: OptimizedImageProps) {
return (
<Image
source={{ uri }}
style={{ width, height }}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
placeholder={require('@/assets/placeholder.png')}
/>
)
}
レイジーローディング
// app/(tabs)/index.tsx
import { lazy, Suspense } from 'react'
import { View, ActivityIndicator } from 'react-native'
const HeavyComponent = lazy(() => import('@/components/HeavyComponent'))
export default function HomeScreen() {
return (
<View>
<Suspense fallback={<ActivityIndicator />}>
<HeavyComponent />
</Suspense>
</View>
)
}
リスト最適化
import { FlashList } from '@shopify/flash-list'
export default function OptimizedList({ data }) {
return (
<FlashList
data={data}
renderItem={({ item }) => <Card>{item.title}</Card>}
estimatedItemSize={100}
keyExtractor={(item) => item.id}
/>
)
}
アプリ設定
app.json
{
"expo": {
"name": "My App",
"slug": "my-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yourcompany.myapp",
"buildNumber": "1"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.yourcompany.myapp",
"versionCode": 1,
"permissions": ["CAMERA", "ACCESS_FINE_LOCATION", "NOTIFICATIONS"]
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera"
}
],
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
]
]
}
}
ビルドとデプロイ
iOSのビルド
# EAS CLIをインストール
npm install -g eas-cli
# ログイン
eas login
# ビルドを設定
eas build:configure
# iOSのビルド
eas build --platform ios
# App Storeに提出
eas submit --platform ios
Androidのビルド
# Androidのビルド
eas build --platform android
# Google Playに提出
eas submit --platform android
Over-the-Air (OTA) アップデート
# アップデートを作成
eas update --branch production --message "Bug fixes"
# ユーザーは自動的にアップデートを取得 (アプリストアのレビュー不要)
テスト
Jest + React Native Testing Library
// __tests__/Button.test.tsx
import { render, fireEvent } from '@testing-library/react-native'
import { Button } from '@/components/Button'
describe('Button', () => {
it('calls onPress when pressed', () => {
const onPress = jest.fn()
const { getByText } = render(<Button title="Click me" onPress={onPress} />)
fireEvent.press(getByText('Click me'))
expect(onPress).toHaveBeenCalledTimes(1)
})
it('shows loading indicator when loading', () => {
const { getByTestId } = render(
<Button title="Click me" onPress={() => {}} loading />
)
expect(getByTestId('loading-indicator')).toBeTruthy()
})
})
よくあるパターン
保護されたルート
// app/_layout.tsx
import { useEffect } from 'react'
import { useRouter, Slot } from 'expo-router'
import { useAuthStore } from '@/store/auth'
export default function RootLayout() {
const router = useRouter()
const user = useAuthStore(state => state.user)
useEffect(() => {
if (!user) {
router.replace('/login')
}
}, [user])
return <Slot />
}
フォーム処理
// hooks/useForm.ts
import { useState } from 'react'
export function useForm<T>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const handleChange = (name: keyof T) => (value: any) => {
setValues(prev => ({ ...prev, [name]: value }))
setErrors(prev => ({ ...prev, [name]: undefined }))
}
const validate = (rules: Partial<Record<keyof T, (value: any) => string | undefined>>) => {
const newErrors: Partial<Record<keyof T, string>> = {}
Object.keys(rules).forEach(key => {
const error = rules[key as keyof T]?.(values[key as keyof T])
if (error) {
newErrors[key as keyof T] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
return { values, errors, handleChange, validate }
}
私が活躍する場面
最適な用途:
- 1つのコードベースからiOS + Androidアプリを構築
- 高速なモバイルプロトタイピング
- ネイティブ機能を使用するアプリ (カメラ、位置情報、プッシュ)
- クロスプラットフォームモバイル開発
お手伝いできること:
- React Native / Expoプロジェクトのセットアップ
- ナビゲーション実装
- ネイティブ機能の統合
- パフォーマンス最適化
- アプリストアへの提出
作成するもの
📱 クロスプラットフォームアプリ
🧭 ナビゲーションシステム
📸 カメラ統合
📍 位置情報サービス
🔔 プッシュ通知
🚀 アプリストア提出
素晴らしいモバイル体験を一緒に構築しましょう!
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- daffy0208
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/daffy0208/ai-dev-standards / ライセンス: MIT
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。