個人的見解が多分に含まれているため技術選定の参考にしていただければ幸いです
マルチプラットフォーム開発
同じコードから異なるプラットフォーム(ios, android, webなど)向けのアプリケーションを提供することができます。
これにより開発効率向上・保守が容易になります。
フレームワーク
現在様々なマルチプラットフォーム開発出来るフレームワークがあります。
今回は一部紹介していきます。
Flutter
Googleが開発、Dartを使用して開発が出来るフレームワークです。
UIをプラットフォームごとに依存せず統一することが出来ます。
ReactNative
Facebookが開発、Javascript・Typescriptを使用して開発が出来るフレームワークです。
名前にある通りReactを使用することが出来るためReactを学んだことがある人はストレスなくマルチプラットフォーム開発をすることが出来ます。
Unity
Unity technologiesが開発、C#を使用して開発が出来るフレームワークです。
主にゲーム開発で使用されているためグラフィックエンジンが強力なため、2Dや3Dグラフィックスが使用出来ます。
上記以外にも様々なフレームワークが存在します。
ただ、今回はよく聞くものを記載させて頂いています。
もし他のフレームワークを使用してマルチプラットフォーム開発をしたい場合は開発したいアプリの要件に合っているか・情報が充実しているかを考えて選定をしてください。
コードを書いていく
多くあるフレームワークの中で今回はFlutterとReactNativeで比較をしていきます。
理由としては自分自身が開発経験のある二種類であること
・採用率が高いフレームワーク
・情報が充実している
というものがあります。
比較するにはコードを書いていくのが一番なので実際にコードを書いていきます。
実装文章はとても長いので時間がある時に読んで頂ければ幸いです。
Flutter実装
Flutter
1. SDKをインストール
開発するOSごとにインストールするファイルは違いますが手順はほぼ同じです。
今回はmacOSにインストールしていきます。
下記URLからインストールをしていきます。
https://docs.flutter.dev/get-started/install
URLを開いたら下記画面が表示されます。
表示されたらCurrent device
と表示されているmacOSをクリックします。
次に初期実行時のアプリを選択していきます。
特に決まっていない場合はiOSを選択していただければ問題ないです。
そうすると下記の画面が表示されます。
Download and install
から記述されている手順通りに進めていただければSDKをインストール出来ます。
2. プロジェクト作成
今回はSDKをメインにFlutterコマンド使用して行きます。
VScodeをメインにFlutterコマンドを使用する場合は読み替えてください。
下記コマンドでプロジェクトを作成することが出来ます。
flutter create flutter_project
出来たらプロジェクトに移動、下記コマンドを実行することで開発環境の実行が出来ます。
サンプルアプリが起動すれば一先ずプロジェクトの作成は完了になります。
cd flutter_project
flutter run
注意としてiOS又はAndroidのシミュレーターを起動しておかないと各シミュレーターで開発環境の実行を行うことが出来ません。
3. APIを使用したアプリを作成
QiitaAPIを使用した記事検索を行える簡単なアプリを作成していきます。
FlutterとReactNative両方で同じアプリを作成して比較出来ればと思います。
QiitaAPIの詳細については下記からお願いします。
ディレクトリ構成
自動で生成されるディレクトリも入っています。
まだまだディレクトリ構成を考え途中のため中途半端な構成に行っています。
.
├── android
│ └── Android関連のファイルが入っているディレクトリ
├── ios
│ └── iOS関連のファイルが入っているディレクトリ
├── lib
│ ├── models
│ │ └── 型定義ファイルが入っているディレクトリ
│ ├── screens
│ │ └── 画面ファイルが入っているディレクトリ
│ ├── widgetts
│ │ └── 再利用可能なUIファイルが入っているディレクトリ
│ └── 実際に作業を行うファイルが入っているディレクトリ
├── linux
│ └── linuxアプリ関連のファイルが入っているディレクトリ
├── macos
│ └── Macアプリ関連のファイルが入っているディレクトリ
├── test
│ └── テスト関連のファイルが入っているディレクトリ
├── web
│ └── webアプリ関連のファイルが入っているディレクトリ
└── windows
└── windowsアプリ関連のファイルが入っているディレクトリ
modelsを整えていく
今回使用するmodelファイルを整えて行きます。
class User {
User({
required this.id,
required this.profileImageUrl,
});
final String id;
final String profileImageUrl;
factory User.fromJson(dynamic json) {
return User(
id: json['id'] as String,
profileImageUrl: json['profile_image_url'] as String);
}
}
import 'package:flutter_project/models/user.dart';
class Article {
Article(
{required this.title,
required this.user,
this.likesCount = 0,
this.tags = const [],
required this.createdAt,
required this.url});
final String title;
final User user;
final int likesCount;
final List<String> tags;
final DateTime createdAt;
final String url;
factory Article.fromJson(dynamic json) {
return Article(
title: json['title'] as String,
user: User.fromJson(json['user']),
createdAt: DateTime.parse(json['created_at'] as String),
url: json['url'] as String,
likesCount:
json['likes_count'] != null ? json['likes_count'] as int : 0,
tags: json['tags'] != null
? List<String>.from(json['tags'].map((tag) => tag['name']))
: []);
}
}
.formJson
はQiitaAPIから受け取ったデータをコードに使用出来るmodel変換する処理です。
modelを書くことが出来たのでテストも書いて行きます。
import 'package:flutter_project/models/user.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('User', () {
test('Userインスタンスの作成', () {
// ユーザーのサンプルデータ
const id = '123';
const profileImageUrl = 'http://example.com/image.jpg';
// Userインスタンスの作成
final user = User(id: id, profileImageUrl: profileImageUrl);
// 各プロパティが正しく設定されていることを確認
expect(user.id, id);
expect(user.profileImageUrl, profileImageUrl);
});
test('User.fromJsonメソッド', () {
// JSONのサンプルデータ
final json = {
'id': '123',
'profile_image_url': 'http://example.com/image.jpg',
};
// fromJsonを使用してUserインスタンスを作成
final user = User.fromJson(json);
// 各プロパティが正しく設定されていることを確認
expect(user.id, json['id']);
expect(user.profileImageUrl, json['profile_image_url']);
});
test('User.fromJsonメソッド - 不正なJSON', () {
// 不完全なJSONデータ
final json = {
'id': '123',
// 'profile_image_url'が欠落
};
// fromJsonを使用してUserインスタンスを作成
expect(
() => User.fromJson(json),
throwsA(isA<TypeError>()),
);
});
});
}
import 'package:flutter_project/models/article.dart';
import 'package:flutter_project/models/user.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Article', () {
test('Articleインスタンスの作成', () {
const title = 'Sample Article';
final user = User(
id: 'testUser', profileImageUrl: 'https://example.com/profile.jpg');
const likesCount = 20;
const tags = [
"flutter",
"dart",
];
final createdAt = DateTime.now();
const url = "https://example.com/sample-article";
final article = Article(
title: title,
tags: tags,
likesCount: likesCount,
user: user,
createdAt: createdAt,
url: url);
expect(article.title, title);
expect(article.user, user);
expect(article.likesCount, likesCount);
expect(article.tags, tags);
expect(article.createdAt, createdAt);
expect(article.url, url);
});
test('Article.fromJsonメソッド', () {
final json = {
'title': 'Sample Article',
'user': {
'id': 'testUser',
'profile_image_url': 'https://example.com/profile.jpg',
},
'likes_count': 42,
'tags': [
{'name': 'flutter'},
{'name': 'dart'}
],
'created_at': '2024-06-22T00:00:00.000Z',
'url': 'https://example.com/sample-article'
};
final article = Article.fromJson(json);
expect(article.title, 'Sample Article');
expect(article.user.id, 'testUser');
expect(article.user.profileImageUrl, 'https://example.com/profile.jpg');
expect(article.likesCount, 42);
expect(article.tags, ['flutter', 'dart']);
expect(article.createdAt, DateTime.parse('2024-06-22T00:00:00.000Z'));
expect(article.url, 'https://example.com/sample-article');
});
test('Article.formデフォルト値', () {
final json = {
'title': 'Sample Article',
'user': {
'id': 'testUser',
'profile_image_url':
'https://webnexty.com/wp-content/uploads/2015/03/ffffff.png',
},
'created_at': '2024-06-22T00:00:00.000Z',
'url': 'https://example.com/sample-article'
};
final article = Article.fromJson(json);
expect(article.likesCount, 0);
expect(article.tags, []);
});
test('Article.fromJsonメソッド - 不正なJSON', () {
// 不完全なJSONデータ
final json = {
'title': 'Sample Article',
'user': {
'id': 'testUser',
'profile_image_url':
'https://webnexty.com/wp-content/uploads/2015/03/ffffff.png',
},
'created_at': '2024-06-22T00:00:00.000Z',
// 'url'が欠落
};
// fromJsonを使用してUserインスタンスを作成
expect(
() => Article.fromJson(json),
throwsA(isA<TypeError>()),
);
});
});
}
テストを実行してエラーが出なければmodelは完成になります。
screens
アプリで表示する画面の作成をしていきます。
今回は検索画面と記事詳細画面を作成します。記事詳細画面はWebviewを使用して簡単に実装していきます。
使用したパッケージは下記になります。
https://pub.dev/packages/webview_flutter
ただ、Webviewを採用したことはミスだと思いました。
理由としてはテストが上手く書けなかったからです。楽を選ぶと後々しわ寄せが来るという良い経験をしました。
今回は時間がないのでWebviewを使用してコードを書いていきます。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_project/models/article.dart';
import 'package:flutter_project/widgets/article_container.dart';
import 'package:http/http.dart' as http;
class SearchScreen extends StatefulWidget {
final http.Client client;
const SearchScreen({super.key, required this.client});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
List<Article> articles = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('検索'),
titleTextStyle: const TextStyle(fontSize: 24, color: Colors.white),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 36),
child: TextField(
style: const TextStyle(fontSize: 18, color: Colors.black),
decoration: const InputDecoration(hintText: '検索ワードを入力してください'),
onSubmitted: (String value) async {
final results = await searchQiita(value);
setState(() => articles = results);
},
),
),
Expanded(
child: ListView(
children: articles
.map((article) => ArticleContainer(article: article))
.toList()))
],
),
);
}
Future<List<Article>> searchQiita(String keyword) async {
final uri = Uri.https('qiita.com', '/api/v2/items',
{'query': 'title:$keyword', 'per_page': '10'});
final String token = dotenv.env['QIITA_ACCESS_TOKEN'] ?? '';
final http.Response res =
await widget.client.get(uri, headers: {'Authorization': 'Bearer $token'});
if (res.statusCode != 200) return [];
final List<dynamic> body = jsonDecode(res.body);
return body.map((dynamic json) => Article.fromJson(json)).toList();
}
}
import 'package:flutter/material.dart';
import 'package:flutter_project/models/article.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ArticleScreen extends StatefulWidget {
const ArticleScreen({super.key, required this.article, this.controller});
final Article article;
final WebViewController? controller;
@override
State<ArticleScreen> createState() => _ArticleScreenState();
}
class _ArticleScreenState extends State<ArticleScreen> {
late WebViewController controller;
@override
void initState() {
super.initState();
controller = widget.controller ?? WebViewController();
controller.loadRequest(Uri.parse(widget.article.url));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.article.title),
),
body: WebViewWidget(controller: controller),
);
}
}
screenのテストはwidgetsのコードを書き終えてから書いていきます
widgets
検索後に表示するQitta記事のカードを作成していきます。
import "package:flutter/material.dart";
import "package:flutter_project/models/article.dart";
import "package:flutter_project/screens/article_screen.dart";
import "package:intl/intl.dart";
class ArticleContainer extends StatelessWidget {
const ArticleContainer({super.key, required this.article});
final Article article;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: GestureDetector(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: ((context) => ArticleScreen(article: article))));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
borderRadius: const BorderRadius.all(Radius.circular(12))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('yyyy/MM/dd').format(article.createdAt),
style: const TextStyle(
color: Colors.black,
fontSize: 12,
),
),
Text(
article.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black),
),
Text('#${article.tags.join(' #')}',
style: const TextStyle(
fontSize: 12,
color: Colors.black,
fontStyle: FontStyle.italic)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
children: [
const Icon(Icons.favorite, color: Colors.black),
Text(article.likesCount.toString(),
style: const TextStyle(
fontSize: 12, color: Colors.black))
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
CircleAvatar(
radius: 26,
backgroundImage:
NetworkImage(article.user.profileImageUrl),
),
const SizedBox(
height: 4,
),
Text(
article.user.id,
style: const TextStyle(
fontSize: 12, color: Colors.black),
)
],
)
],
)
],
)),
));
}
}
widgetsが書き終わったのでscreensのコードと一緒にテストを書いていきます。
mockitoを使用したテストになっています。
https://pub.dev/packages/mockito
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_project/screens/search_screen.dart';
import 'package:flutter_project/widgets/article_container.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// モックファイルをインポート
import 'search_screen_test.mocks.dart';
// モッククラスを生成
@GenerateMocks([http.Client])
void main() {
setUpAll(() async {
HttpOverrides.global = null;
await dotenv.load();
});
group('SearchScreen', () {
late MockClient mockClient;
setUp(() {
mockClient = MockClient();
});
testWidgets('検索が成功したときに記事が表示される', (WidgetTester tester) async {
// サンプルのJSONレスポンス
final List<Map<String, dynamic>> mockResponse = [
{
"title": 'Sample Article 1',
"user": {
"id": 'user1',
"profile_image_url": 'https://example.com/user1.jpg',
},
"likes_count": 10,
"tags": [
{"name": 'flutter'},
{"name": 'dart'}
],
"created_at": '2024-06-22T00:00:00.000Z',
"url": 'https://example.com/sample-article1',
},
{
"title": 'Sample Article 2',
"user": {
"id": 'user2',
"profile_image_url": 'https://example.com/user2.jpg',
},
"likes_count": 20,
"tags": [
{"name": 'flutter'},
{"name": 'dart'}
],
"created_at": '2024-06-22T00:00:00.000Z',
"url": 'https://example.com/sample-article2',
}
];
// モックHTTPクライアントの設定
when(mockClient.get(
any,
headers: anyNamed('headers'),
)).thenAnswer((_) async => http.Response(jsonEncode(mockResponse), 200));
// テストウィジェットを構築
await tester
.pumpWidget(MaterialApp(home: SearchScreen(client: mockClient)));
// TextFieldにテキストを入力し、送信
await tester.enterText(find.byType(TextField), 'Test');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
// ArticleContainerウィジェットが表示されていることを確認
expect(find.byType(ArticleContainer), findsNWidgets(mockResponse.length));
for (final article in mockResponse) {
expect(find.text(article['title']), findsOneWidget);
}
});
testWidgets('検索が失敗したときに記事が表示されない', (WidgetTester tester) async {
// エラーレスポンスを返すようにモックHTTPクライアントを設定
when(mockClient.get(
any,
headers: anyNamed('headers'),
)).thenAnswer((_) async => http.Response('Not Found', 404));
// テストウィジェットを構築
await tester
.pumpWidget(MaterialApp(home: SearchScreen(client: mockClient)));
// TextFieldにテキストを入力し、送信
await tester.enterText(find.byType(TextField), 'Test');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
// ArticleContainerウィジェットが表示されていないことを確認
expect(find.byType(ArticleContainer), findsNothing);
});
});
}
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_project/models/article.dart';
import 'package:flutter_project/models/user.dart';
import 'package:flutter_project/widgets/article_container.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'article_container_test.mocks.dart';
@GenerateMocks([User, Article])
void main() {
setUp(() {
HttpOverrides.global = null;
});
testWidgets('displays article information correctly',
(WidgetTester tester) async {
final mockUser = MockUser();
when(mockUser.id).thenReturn('user1');
when(mockUser.profileImageUrl)
.thenReturn('https://example.com/profile.jpg');
final mockArticle = MockArticle();
when(mockArticle.title).thenReturn('Sample Article');
when(mockArticle.user).thenReturn(mockUser);
when(mockArticle.likesCount).thenReturn(42);
when(mockArticle.tags).thenReturn(['flutter', 'dart']);
when(mockArticle.createdAt)
.thenReturn(DateTime.parse('2024-06-22T00:00:00.000Z'));
when(mockArticle.url).thenReturn('https://example.com/sample-article');
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ArticleContainer(article: mockArticle),
),
),
);
await tester.pumpAndSettle();
expect(find.text('2024/06/22'), findsOneWidget);
expect(find.text('Sample Article'), findsOneWidget);
expect(find.text('#flutter #dart'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.text('user1'), findsOneWidget);
});
}
書き終わったら下記コマンドを実行してモックを作成していきます。
flutter pub run build_runner build
その後テストを行って問題なくテストが完了したら全てのコード実装は完了になります。
ReactNative実装
React Native
1. プロジェクト作成
reactNativeのためnodeさえ入っていればプロジェクトの作成が可能です
テンプレートを使用してプロジェクト作成も可能ですが、今回はテンプレートを使用してコードは書かないため下記コマンドを実行して作成していきます。
npx create-expo-app react_native_project
作成したプロジェクトに移動して開発環境を立ち上げてみてください。
cd react_native_project
npm run ios
下記画面がエミュレーターで表示されれば作成は完了です。
2. APIを使用したアプリを作成
Flutterと同じくQiitaAPIを使用して簡単な記事検索アプリを作成していきます.
ディレクトリ構成
大規模開発には向かないディレクトリ構成になっていますが気にせず見て頂ければ幸いです。
.
├── __mocks__
│ └── テストで使用するモック関連のファイルが入っているディレクトリ
├── android
│ └── Androidアプリ関連のファイルが入っているディレクトリ
├── app
│ └── 画面関係のファイルが入っているディレクトリ
├── assets
│ └── 静的コンテンツファイルが入っているディレクトリ
├── components
│ └── コンポーネントファイルが入っているディレクトリ
├── hooks
│ └── カスタムフックファイルが入っているディレクトリ
├── ios
│ └── iOSアプリ関連のファイルが入っているディレクトリ
├── models
│ └── 型定義ファイルが入っているディレクトリ
├── states
│ └── recoil関連のファイルが入っているディレクトリ
└── utils
└── 関数定義ファイルが入っているディレクトリ
jestを整える
今回のテストで使用するjestを整えていきます。
import { ReactDOM } from "react";
export const Link = ({ children }: { children: ReactDOM }) => children;
export const useLinkTo = jest.fn();
module.exports = {
preset: "jest-expo",
transform: {
"^.+\\.(js|jsx|ts|tsx)$": "babel-jest",
},
testMatch: ["**/__tests__/**/*.(js|jsx|ts|tsx)"],
testEnvironment: "jsdom",
moduleNameMapper: {
"^expo-router$": "<rootDir>/__mocks__/expo-router.ts",
},
setupFilesAfterEnv: ["@testing-library/jest-native/extend-expect"],
};
__mocks__/expo-router.ts
expo-routerモックを使わずにテストを行うとエラーが表示されてしまうため今回のようにモックを作成しています。
utilsを整える
今回使用するutilファイルを整えて行きます。
QiitaAPIからデータを取得する処理を書いていきます。
import { ENV } from "@/ENV";
export type UserResponse = {
description: string;
facebook_id: string;
followees_count: number;
followers_count: number;
github_login_name: string;
id: string;
items_count: number;
linkedin_id: string;
location: string;
name: string;
organization: string;
permanent_id: number;
profile_image_url: string;
team_only: boolean;
twitter_screen_name: string;
website_url: string;
};
export type ArticleResponse = {
rendered_body: string;
body: string;
coediting: boolean;
comments_count: number;
created_at: string;
group: {
created_at: string;
description: string;
name: string;
private: boolean;
updated_at: string;
url_name: string;
};
id: string;
likes_count: number;
private: boolean;
reactions_count: number;
tags: [
{
name: string;
versions: string[];
}
];
title: string;
updated_at: string;
url: string;
user: UserResponse;
page_views_count: number;
team_membership: {
name: string;
};
};
export class QiitaUtil {
private apiUrl: string;
private token: string;
private perPage: number = 10;
constructor() {
this.apiUrl = "https://qiita.com/api/v2/items";
this.token = ENV.QIITA_ACCESS_TOKEN;
}
searchQiita = async (title: string): Promise<ArticleResponse[]> => {
const query = `?per_page=${this.perPage}&query=title:${title}`;
const url = `${this.apiUrl}${query}`;
try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(data);
return data;
} catch (e) {
if (e instanceof Error) {
throw new Error(e.message);
} else {
throw new Error("An unknown error occurred");
}
}
};
}
fetch処理を行っているのでテストを行う際はjest-fetch-mock
をインストールしていきます。
npm install --save-dev jest-fetch-mock
インストールが出来たらテストを書いていきます。
import { ArticleResponse, QiitaUtil } from "../QiitaUtil";
jest.mock("@/ENV", () => ({
ENV: {
QIITA_ACCESS_TOKEN: "test_token",
},
}));
const fetchMock = require("jest-fetch-mock");
fetchMock.enableMocks();
describe("QiitaUtil", () => {
beforeEach(() => {
fetchMock.resetMocks();
});
it("searchQiita returns articles based on title", async () => {
const mockResponse: ArticleResponse[] = [
{
rendered_body: "<p>Test article</p>",
body: "Test article",
coediting: false,
comments_count: 0,
created_at: "2024-06-25T00:00:00+00:00",
id: "test_id",
likes_count: 10,
private: false,
reactions_count: 0,
tags: [
{
name: "test",
versions: ["1.0"],
},
],
title: "Test Article",
updated_at: "2024-06-25T00:00:00+00:00",
url: "https://qiita.com/test_id",
user: {
description: "Test user",
facebook_id: "test_facebook",
followees_count: 10,
followers_count: 20,
github_login_name: "test_github",
id: "test_user",
items_count: 5,
linkedin_id: "test_linkedin",
location: "Tokyo",
name: "Test User",
organization: "Test Organization",
permanent_id: 1,
profile_image_url: "https://example.com/profile.jpg",
team_only: false,
twitter_screen_name: "test_twitter",
website_url: "https://example.com",
},
page_views_count: 100,
team_membership: { name: "" },
group: {
created_at: "",
description: "",
name: "",
private: false,
updated_at: "",
url_name: "",
},
},
];
fetchMock.mockResponseOnce(JSON.stringify(mockResponse));
const qiitaUtil = new QiitaUtil();
const articles = await qiitaUtil.searchQiita("Test");
expect(articles).toEqual(mockResponse);
expect(fetchMock).toHaveBeenCalledWith(
"https://qiita.com/api/v2/items?per_page=10&query=title:Test",
{
method: "GET",
headers: {
Authorization: `Bearer test_token`,
"Content-Type": "application/json",
},
}
);
});
it("throws an error when the API call fails", async () => {
fetchMock.mockReject(new Error("API call failed"));
const qiitaUtil = new QiitaUtil();
await expect(qiitaUtil.searchQiita("Test")).rejects.toThrow(
"API call failed"
);
});
});
テストが通ったらutilsファイルは完了です。
modelsを整える
今回使用するmodelファイルを整えて行きます。
import { UserResponse } from "@/utils/QiitaUtil";
export class User {
id: string;
profileImageUrl: string;
constructor(user: UserResponse) {
this.id = user.id;
this.profileImageUrl = user.profile_image_url;
}
}
import { ArticleResponse } from "@/utils/QiitaUtil";
import { User } from "./user";
export class Article {
title: string;
user: User;
likesCount: number;
tags: string[];
created_at: Date;
url: string;
constructor(article: ArticleResponse) {
this.title = article.title;
this.user = new User(article.user);
this.likesCount = article.likes_count;
this.tags = article.tags.map((tag) => tag.name);
this.created_at = new Date(article.created_at);
this.url = article.url;
}
}
内容的にはそこまで難しくないです。
書き終わったらテストも書いていきます。
import { UserResponse } from "@/utils/QiitaUtil";
import { User } from "../user";
const mockUserResponse: UserResponse = {
id: "testUser",
profile_image_url: "https://example.com",
description: "",
facebook_id: "",
followees_count: 0,
followers_count: 0,
github_login_name: "",
items_count: 0,
linkedin_id: "",
location: "",
name: "",
organization: "",
permanent_id: 0,
team_only: false,
twitter_screen_name: "",
website_url: "",
};
describe("User", () => {
it("should create an User object from UserResponse", () => {
const expectedId = mockUserResponse.id;
const expectedProfileImageUrl = mockUserResponse.profile_image_url;
const user = new User(mockUserResponse);
expect(user.id).toBe(expectedId);
expect(user.profileImageUrl).toBe(expectedProfileImageUrl);
});
});
import { ArticleResponse } from "@/utils/QiitaUtil";
import { Article } from "../article";
import { User } from "../user";
const mockArticleResponse: ArticleResponse = {
title: "Test Article",
user: {
id: "testUser",
profile_image_url: "https://example.com",
description: "",
facebook_id: "",
followees_count: 0,
followers_count: 0,
github_login_name: "",
items_count: 0,
linkedin_id: "",
location: "",
name: "",
organization: "",
permanent_id: 0,
team_only: false,
twitter_screen_name: "",
website_url: "",
},
likes_count: 10,
tags: [{ name: "technology", versions: [] }],
created_at: "2024-06-23T12:00:00Z",
url: "https://example.com/test-article",
rendered_body: "",
body: "",
coediting: false,
comments_count: 0,
group: {
created_at: "",
description: "",
name: "",
private: false,
updated_at: "",
url_name: "",
},
id: "",
private: false,
reactions_count: 0,
updated_at: "",
page_views_count: 0,
team_membership: {
name: "",
},
};
describe("Article", () => {
it("should create an Article object from ArticleResponse", () => {
const expectedTitle = mockArticleResponse.title;
const expectedUser = new User(mockArticleResponse.user);
const expectedLikesCount = mockArticleResponse.likes_count;
const expectedTags = mockArticleResponse.tags.map((tag) => tag.name);
const expectedCreatedAt = new Date(mockArticleResponse.created_at);
const expectedUrl = mockArticleResponse.url;
const article = new Article(mockArticleResponse);
expect(article.title).toBe(expectedTitle);
expect(article.user).toEqual(expectedUser);
expect(article.likesCount).toBe(expectedLikesCount);
expect(article.tags).toEqual(expectedTags);
expect(article.created_at.getTime()).toBe(expectedCreatedAt.getTime());
expect(article.url).toBe(expectedUrl);
});
});
テストが通ればmodelsの作成は完了です。
カスタムフックを整える
import { Article } from "@/models/article";
import { QiitaUtil } from "@/utils/QiitaUtil";
import { Dispatch, SetStateAction, useState } from "react";
type UseSearchQiitaReturn = {
title: string;
setTitle: Dispatch<SetStateAction<string>>;
articles: Article[];
searchQiitaHandler: (title: string) => Promise<void>;
};
type UseSearchQiita = () => UseSearchQiitaReturn;
export const useSearchQiita: UseSearchQiita = () => {
const [title, setTitle] = useState<string>("");
const [articles, setArticles] = useState<Article[]>([]);
const searchQiitaHandler = async (title: string): Promise<void> => {
if (!title) return;
const qiitaUtil = new QiitaUtil();
const articlesByQiita = await qiitaUtil.searchQiita(title);
setArticles((v) => [
...articlesByQiita.map((article) => new Article(article)),
]);
};
return {
title,
setTitle,
articles,
searchQiitaHandler,
};
};
テストも整えていきます。
import { useSearchQiita } from "@/hooks/useSearchQiita";
import { Article } from "@/models/article";
import { QiitaUtil } from "@/utils/QiitaUtil";
import { act, renderHook, waitFor } from "@testing-library/react-native";
jest.mock("@/utils/QiitaUtil");
describe("useSearchQiita", () => {
it("should update articles when searchQiitaHandler is called", async () => {
// Arrange
const mockArticles: Article[] = [
{
title: "Sample Article 1",
user: { id: "user1", profileImageUrl: "https://example.com" },
likesCount: 5,
tags: ["technology"],
created_at: new Date(),
url: "https://example.com/mock-article-1",
},
{
title: "Sample Article 2",
user: { id: "user2", profileImageUrl: "https://example.com" },
likesCount: 10,
tags: ["technology"],
created_at: new Date(),
url: "https://example.com/mock-article-2",
},
];
const mockTitle = "Sample Article 1";
const mockSearchQiita = jest.fn().mockResolvedValue(mockArticles);
(QiitaUtil as jest.Mock).mockImplementation(() => ({
searchQiita: mockSearchQiita,
}));
// Act
const { result } = renderHook(() => useSearchQiita());
act(() => {
result.current.setTitle(mockTitle);
});
await act(async () => {
await result.current.searchQiitaHandler(mockTitle);
});
// Assert
await waitFor(() => {
expect(mockSearchQiita).toHaveBeenCalledWith(mockTitle);
expect(result.current.articles).toHaveLength(mockArticles.length);
expect(result.current.articles[0].title).toBe(mockArticles[0].title);
expect(result.current.articles[1].title).toBe(mockArticles[1].title);
});
});
});
テストが通ればカスタムフックの実装は完了です。
recoilを整える
実験的にreactNativeでrecoilを使用と思い導入をしました。
export const ATOMS_KEY = {
ARTICLE: "articleState",
} as const;
export type ATOMS_KEY = (typeof ATOMS_KEY)[keyof typeof ATOMS_KEY];
export const SELECTOR_KEY = {
ARTICLE_URL: "articleUrlSelector",
ARTICLE_TITLE: "articleTitleSelector",
};
export type SELECTOR_KEY = (typeof SELECTOR_KEY)[keyof typeof SELECTOR_KEY];
import { Article } from "@/models/article";
import { atom } from "recoil";
import { ATOMS_KEY } from "../keys";
export const articleAtom = atom<Article>({
key: ATOMS_KEY.ARTICLE,
default: {
title: "",
user: {
id: "",
profileImageUrl: "",
},
likesCount: 0,
tags: [],
created_at: new Date(),
url: "",
},
});
import { selector } from "recoil";
import { articleAtom } from "../atoms/articleAtom";
import { SELECTOR_KEY } from "../keys";
export const articleUrlState = selector({
key: SELECTOR_KEY.ARTICLE_URL,
get: ({ get }) => {
const article = get(articleAtom);
return article.url;
},
});
export const articleTitleState = selector({
key: SELECTOR_KEY.ARTICLE_TITLE,
get: ({ get }) => {
const article = get(articleAtom);
return article.title;
},
});
ここまで書けたらテストも書いていきます。
import { Article } from "@/models/article";
import { articleAtom } from "@/states/atoms/articleAtom";
import { ArticleResponse } from "@/utils/QiitaUtil";
import { render } from "@testing-library/react-native";
import React, { useEffect } from "react";
import { Image, Text, View } from "react-native";
import { RecoilRoot, useRecoilState } from "recoil";
const mockArticleResponse: ArticleResponse = {
title: "Test Article",
user: {
id: "testUser",
profile_image_url: "https://example.com",
description: "",
facebook_id: "",
followees_count: 0,
followers_count: 0,
github_login_name: "",
items_count: 0,
linkedin_id: "",
location: "",
name: "",
organization: "",
permanent_id: 0,
team_only: false,
twitter_screen_name: "",
website_url: "",
},
likes_count: 10,
tags: [{ name: "technology", versions: [] }],
created_at: "2024-06-23T12:00:00Z",
url: "https://example.com/test-article",
rendered_body: "",
body: "",
coediting: false,
comments_count: 0,
group: {
created_at: "",
description: "",
name: "",
private: false,
updated_at: "",
url_name: "",
},
id: "",
private: false,
reactions_count: 0,
updated_at: "",
page_views_count: 0,
team_membership: {
name: "",
},
};
// テスト用コンポーネント
const TestComponent = () => {
const [article, setArticle] = useRecoilState(articleAtom);
useEffect(() => {
const mockArticle = new Article(mockArticleResponse);
setArticle(mockArticle);
}, []);
return (
<View>
<Text testID="title">{article.title}</Text>
<Image
testID="profileImageUrl"
source={{ uri: article.user.profileImageUrl }}
style={{ width: 100, height: 100 }}
/>
<Text testID="likesCount">{article.likesCount}</Text>
<Text testID="tags">{article.tags.join(", ")}</Text>
<Text testID="created_at">{new Date(article.created_at).toString()}</Text>
<Text testID="url">{article.url}</Text>
</View>
);
};
describe("articleAtom", () => {
it("should have the correct default values", () => {
const { getByTestId } = render(
<RecoilRoot>
<TestComponent />
</RecoilRoot>
);
expect(getByTestId("title").props.children).toBe("Test Article");
expect(getByTestId("profileImageUrl").props.source.uri).toBe(
"https://example.com"
);
expect(getByTestId("likesCount").props.children).toBe(10);
expect(getByTestId("tags").props.children).toBe("technology");
expect(getByTestId("created_at").props.children).toBe(
new Date("2024-06-23T12:00:00Z").toString()
);
expect(getByTestId("url").props.children).toBe(
"https://example.com/test-article"
);
});
});
import { articleAtom } from "@/states/atoms/articleAtom";
import {
articleTitleState,
articleUrlState,
} from "@/states/selectors/articleSelector";
import { render } from "@testing-library/react-native";
import React from "react";
import { Text } from "react-native";
import { RecoilRoot, useRecoilValue } from "recoil";
// テスト用コンポーネント
const TestUrlComponent = () => {
const url = useRecoilValue(articleUrlState);
return <Text testID="url">{url}</Text>;
};
const TestTitleComponent = () => {
const title = useRecoilValue(articleTitleState);
return <Text testID="title">{title}</Text>;
};
describe("articleUrlState and articleTitleState selectors", () => {
it("should return the correct default url from articleUrlState", () => {
const { getByTestId } = render(
<RecoilRoot>
<TestUrlComponent />
</RecoilRoot>
);
expect(getByTestId("url").props.children).toBe("");
});
it("should return the correct default title from articleTitleState", () => {
const { getByTestId } = render(
<RecoilRoot>
<TestTitleComponent />
</RecoilRoot>
);
expect(getByTestId("title").props.children).toBe("");
});
it("should return the updated url from articleUrlState", () => {
const initialState = {
articleAtom: {
title: "Test Title",
user: {
id: "1",
profileImageUrl: "https://example.com/image.jpg",
},
likesCount: 10,
tags: ["test", "recoil"],
created_at: new Date(),
url: "https://example.com/article",
},
};
const { getByTestId } = render(
<RecoilRoot
initializeState={({ set }) =>
set(articleAtom, initialState.articleAtom)
}
>
<TestUrlComponent />
</RecoilRoot>
);
expect(getByTestId("url").props.children).toBe(
"https://example.com/article"
);
});
it("should return the updated title from articleTitleState", () => {
const initialState = {
articleAtom: {
title: "Test Title",
user: {
id: "1",
profileImageUrl: "https://example.com/image.jpg",
},
likesCount: 10,
tags: ["test", "recoil"],
created_at: new Date(),
url: "https://example.com/article",
},
};
const { getByTestId } = render(
<RecoilRoot
initializeState={({ set }) =>
set(articleAtom, initialState.articleAtom)
}
>
<TestTitleComponent />
</RecoilRoot>
);
expect(getByTestId("title").props.children).toBe("Test Title");
});
});
テストが正常に動けばrecoilの実装は完了です。
componentsを整える
簡単なコンポーネントを書いていきます。
import { Article } from "@/models/article";
import { articleAtom } from "@/states/atoms/articleAtom";
import { FontAwesome } from "@expo/vector-icons";
import { format } from "date-fns";
import { Link } from "expo-router";
import React, { FC } from "react";
import {
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
useColorScheme,
} from "react-native";
import { Colors } from "react-native/Libraries/NewAppScreen";
import { useRecoilState } from "recoil";
type Props = {
article: Article;
};
export const ArticleCard: FC<Props> = ({ article }) => {
const colorScheme = useColorScheme();
const [, setArticle] = useRecoilState(articleAtom);
const setArticleState = (article: Article) => {
setArticle(article);
};
return (
<View style={styles.card}>
<Link href="/modal" asChild onPress={() => setArticleState(article)}>
<TouchableOpacity style={styles.modalLink} testID="modal-link">
<View style={styles.header}>
<Text style={styles.createdAt}>
{format(article.created_at, "yyyy/MM/dd")}
</Text>
<Text>{article.title}</Text>
<View style={styles.tags}>
{article.tags.map((tag, index) => (
<Text key={index}>{tag}</Text>
))}
</View>
</View>
<View style={styles.footer}>
<View style={styles.like}>
<FontAwesome
name="heart"
size={25}
color={Colors[colorScheme ?? "light"].text}
/>
<Text>{article.likesCount}</Text>
</View>
<View style={styles.user}>
<Image
style={styles.icon}
testID="article-image"
source={{
uri: article.user.profileImageUrl,
}}
/>
<Text>{article.user.id}</Text>
</View>
</View>
</TouchableOpacity>
</Link>
</View>
);
};
const styles = StyleSheet.create({
card: {
borderWidth: 1,
borderRadius: 8,
padding: 16,
borderColor: "#999",
gap: 20,
width: "100%",
},
createdAt: {
marginBottom: 8,
},
icon: {
width: 32,
height: 32,
borderRadius: 50,
},
tags: {
justifyContent: "flex-start",
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
tag: {
marginRight: 8,
},
like: {
alignItems: "center",
gap: 4,
},
user: {
alignItems: "flex-end",
gap: 8,
},
header: {
flexDirection: "column",
},
footer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
width: "100%",
},
modalLink: {
gap: 20,
width: "100%",
},
});
テストも書いていきます。
import { ArticleCard } from "@/components/ArticleCard";
import { Article } from "@/models/article";
import { articleAtom } from "@/states/atoms/articleAtom";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import React from "react";
import { RecoilRoot, useRecoilState } from "recoil";
const mockArticle: Article = {
title: "Test Article",
user: { id: "testUser", profileImageUrl: "https://example.com/image.png" },
likesCount: 10,
tags: ["react", "typescript"],
created_at: new Date("2023-01-01"),
url: "https://example.com/test-article",
};
describe("ArticleCard", () => {
it("renders correctly", () => {
const { getByText, getByTestId } = render(
<RecoilRoot>
<ArticleCard article={mockArticle} />
</RecoilRoot>
);
expect(getByText("Test Article")).toBeTruthy();
expect(getByText("2023/01/01")).toBeTruthy();
expect(getByText("react")).toBeTruthy();
expect(getByText("typescript")).toBeTruthy();
expect(getByText("10")).toBeTruthy();
expect(getByText("testUser")).toBeTruthy();
expect(getByTestId("article-image").props.source.uri).toBe(
"https://example.com/image.png"
);
});
it("calls setArticleState when the link is pressed", () => {
const setArticle = jest.fn();
const useRecoilStateMock = () => [null, setArticle];
jest
.spyOn(require("recoil"), "useRecoilState")
.mockImplementation(useRecoilStateMock);
const { getByTestId } = render(
<RecoilRoot>
<ArticleCard article={mockArticle} />
</RecoilRoot>
);
const modalLink = getByTestId("modal-link");
fireEvent.press(modalLink);
waitFor(() => {
const state = useRecoilState(articleAtom);
expect(state[0]).toEqual(mockArticle);
});
});
});
テストが通ったらcomponentsの実装完了です。
画面を整える
細かいコードが実装出来たので画面を実装していきます。
import { articleUrlState } from "@/states/selectors/articleSelector";
import React from "react";
import { SafeAreaView, StyleSheet, View } from "react-native";
import { WebView } from "react-native-webview";
import { useRecoilValue } from "recoil";
export default function ModalScreen() {
const articleUrl = useRecoilValue(articleUrlState);
return (
<View style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<WebView source={{ uri: articleUrl }} style={styles.webview} />
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
webview: {},
});
import { useColorScheme } from "@/hooks/useColorScheme";
import { Stack } from "expo-router";
import React from "react";
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from "expo-router";
export default function SearchLayout() {
const colorScheme = useColorScheme();
return (
<Stack>
<Stack.Screen
name="index"
options={{
title: "検索画面",
}}
/>
</Stack>
);
}
import { ArticleCard } from "@/components/ArticleCard";
import { useSearchQiita } from "@/hooks/useSearchQiita";
import React from "react";
import { Button, ScrollView, StyleSheet, TextInput, View } from "react-native";
export default function SearchScreen() {
const { title, setTitle, articles, searchQiitaHandler } = useSearchQiita();
return (
<View>
<TextInput
style={styles.input}
value={title}
onChangeText={(title) => setTitle(title)}
placeholder="Qiita記事検索"
/>
<Button onPress={() => searchQiitaHandler(title)} title="検索" />
<View
style={{
alignItems: "center",
}}
>
<View style={styles.separator} />
</View>
<ScrollView style={styles.scroll} contentContainerStyle={{ rowGap: 20 }}>
{articles.map((article, index) => {
return <ArticleCard article={article} key={index} />;
})}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
input: {
height: 40,
margin: 12,
borderWidth: 1,
padding: 10,
},
separator: {
marginVertical: 30,
height: 1,
width: "80%",
backgroundColor: "rgba(100,100,100, 0.5)",
},
scroll: {
height: "70%",
paddingHorizontal: 20,
},
});
書けたらテストも書いていきます。
import { useSearchQiita } from "@/hooks/useSearchQiita";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import React from "react";
import { RecoilRoot } from "recoil";
import SearchScreen from "../index";
jest.mock("@/hooks/useSearchQiita");
describe("SearchScreen", () => {
it("displays articles when search is performed", async () => {
const mockArticles = [
{
title: "Sample Article1",
user: {
id: "user1",
profileImageUrl: "https://example.com/profile1.jpg",
},
likesCount: 5,
tags: ["React Native"],
created_at: new Date(),
url: "https://example.com/article1",
},
{
title: "Sample Article2",
user: {
id: "user2",
profileImageUrl: "https://example.com/profile2.jpg",
},
likesCount: 10,
tags: ["React Native"],
created_at: new Date(),
url: "https://example.com/article2",
},
];
const inputTitle = "Article";
const mockSetTitle = jest.fn();
const mockSearchQiitaHandler = jest.fn();
(useSearchQiita as jest.Mock).mockReturnValue({
title: inputTitle,
setTitle: mockSetTitle,
articles: mockArticles,
searchQiitaHandler: mockSearchQiitaHandler,
});
const { getByPlaceholderText, getByText, getAllByText } = render(
<RecoilRoot>
<SearchScreen />
</RecoilRoot>
);
fireEvent.changeText(getByPlaceholderText("Qiita記事検索"), inputTitle);
await waitFor(() => {
expect(mockSetTitle).toHaveBeenCalledWith(inputTitle);
});
fireEvent.press(getByText("検索"));
await waitFor(() => {
expect(mockSearchQiitaHandler).toHaveBeenCalledWith(inputTitle);
expect(getAllByText(/Sample Article/)).toHaveLength(2);
});
});
});
テストも通れば全てのコード実装完了になります。
実際にコード書いて
コードを書いたことでFlutterとReactNativeのメリット・デメリットを実感出来ました。
Flutter
メリット
- VSCodeとの親和性が高い
- Typescriptチックに書ける
この2つは自分に合っていてとても書きやすかったです。
デメリット
- Flutter独自の書き方
- テスト
Flutter独自の書き方のため新たに覚える必要があるが使い慣れている言語があれば問題ないです。
テストは難しかったです。jestに慣れているというのもありエラーばかりで大変でした。
jestも最初は難しかったので慣れですね。
ReactNative
メリット
- Reactが出来ればすぐにアプリ開発が出来る
この一つが最大のメリットになります。
もし、プロジェクトでReactを使ってチーム開発をしていたら直ぐにチームでReactNativeを使った開発が出来ます。
デメリット
- 開発環境構築が大変
ReactNativeでアプリ開発をする際に環境構築構築に時間をかけました。
前まではここまで大変じゃなかったと思います。バージョンアップをして難しくなったのかなと思いました。
環境構築は最初だけなので構築さえ出来れば後は開発に時間を割けるかと思います。
どちらの選ぶか
どちらでも良いと思いました。
慣れ次第でどちらでも扱えますが最後はチーム内で相談してどちらを選ぶかを決めるかなっと思います。
テストのことを考えるとReactNativeに軍配が上がります。
まとめ
アプリ開発を想定してどちらが良いか実際にコードを書いてきましたが、FlutterとReactNativeは慣れやチーム全体との相談でどちらでもアプリ開発に適していると思います。
テストなしで考えたらFlutterですが、テストなしの開発は考えられないのでReactNativeが自分的は扱いやかったです。
実際にコードを書かないと気付けない内容だったので良い経験をしました。