はじめに
前回のログインアプリをflutter_riverpodを使用して、MVVM風にリファクタしてみました。
前回作成したアプリ(https://qiita.com/yufuku/items/24dac97e6052b2571386)
今回のゴールはview(ui)側で実装していた処理をviewModelに移行して、view側ではstateを持たないようにすることです。
最初のフォルダ構成とコードは下記になります。
└──lib
├── main.dart
├── requester
│ └── requester.dart
└── ui
├── hello.dart
└── login.dart
import 'package:flutter/material.dart';
import 'package:authentication_frontend/ui/login.dart';
void main() {
runApp(const LoginApp());
}
import 'package:authentication_frontend/ui/hello.dart';
import 'package:authentication_frontend/requester/requester.dart';
import 'package:flutter/material.dart';
class LoginApp extends StatelessWidget {
const LoginApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ログイン画面',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const LoginPage(title: 'Login Page'),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _userNameTextController = TextEditingController();
final _passwordNameTextController = TextEditingController();
final FocusNode _userNamefocusNode = FocusNode();
final FocusNode _passwordNameFocusNode = FocusNode();
var _errorMessage = "";
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_errorMessage, style: const TextStyle(color: Colors.red)),
TextField(
controller: _userNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Username',
),
focusNode: _userNamefocusNode,
),
const SizedBox(height: 12.0),
TextField(
controller: _passwordNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Password',
),
focusNode: _passwordNameFocusNode,
obscureText: true,
),
ButtonBar(
children: <Widget>[
TextButton(
child: const Text('CANCEL'),
onPressed: () {
_userNameTextController.clear();
_passwordNameTextController.clear();
},
),
ElevatedButton(
child: const Text('LOGIN'),
onPressed: () {
Requester()
.loginRequester(_userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
setState(() {
_errorMessage = "";
});
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
debugPrint(error.toString());
_userNameTextController.clear();
_passwordNameTextController.clear();
setState(() {
_errorMessage = "ログインに失敗しました。ユーザー名かパスワードが間違っています。";
});
});
}),
ElevatedButton(
child: const Text('SIGNUP'),
onPressed: () {
Requester()
.signUpRequester(_userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
setState(() {
_errorMessage = "";
});
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
debugPrint(error.toString());
_userNameTextController.clear();
_passwordNameTextController.clear();
setState(() {
_errorMessage = "ユーザーの作成に失敗しました。既に登録済みのユーザーです。";
});
});
}),
],
),
],
),
),
);
}
}
import 'package:authentication_frontend/requester/requester.dart';
import 'package:flutter/material.dart';
class Hello extends StatefulWidget {
const Hello({Key? key}) : super(key: key);
@override
_HelloState createState() => _HelloState();
}
class _HelloState extends State<Hello> {
var _message = "";
@override
void initState() {
super.initState();
Requester().helloRequester().then((value) {
setState(() {
_message = value;
});
}).onError((error, stackTrace) {
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("認証に失敗しました。再ログインをお願いします。"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context), child: Text("OK")),
],
);
}).then((_) {
Navigator.pop(context);
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Hello"),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
TextButton(
onPressed: () {
Requester().logoutRequester().then((_) {
Navigator.pop(context);
});
},
child: const Text('LOGOUT',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16.0)),
),
],
),
body: Padding(
padding: EdgeInsets.only(top: 100),
child: Center(
child: Column(
children: [Text(_message, style: TextStyle(fontSize: 50.0))],
),
),
),
);
}
}
今回は上記のファイルを変更していきます。
また、今回は変更の対象ではないですが、apiのリクエスト部分の処理はrequester.dartにまとめています。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class TokenExipireException implements Exception {
final String message;
const TokenExipireException(this.message);
@override
String toString() => message;
}
class Requester {
String uri = defaultTargetPlatform == TargetPlatform.android
? 'http://10.0.2.2:8080/v1/'
: 'http://127.0.0.1:8080/v1/';
Map<String, String> headers = {
"Content-Type": "application/json",
};
final storage = new FlutterSecureStorage();
Requester() {}
Future<void> loginRequester(String name, String password) async {
var loginUri = uri + "auth/login";
var request = AuthRequest(name: name, password: password);
final response = await http.post(Uri.parse(loginUri),
body: json.encode(request.toJson()), headers: headers);
if (response.statusCode == 200) {
Map<String, dynamic> decoded = json.decode(response.body);
var loginResponse = AuthResponse.fromJson(decoded);
debugPrint(loginResponse.accessToken);
await storage.write(key: "accessToken", value: loginResponse.accessToken);
await storage.write(key: "refreshToken", value: loginResponse.refreshToken);
} else {
throw Exception("Login Error");
}
}
Future<void> signUpRequester(String name, String password) async {
var signUpUri = uri + "auth/signup";
var request = AuthRequest(name: name, password: password);
final response = await http.post(Uri.parse(signUpUri),
body: json.encode(request.toJson()), headers: headers);
if (response.statusCode == 200) {
Map<String, dynamic> decoded = json.decode(response.body);
var signUpResponse = AuthResponse.fromJson(decoded);
await storage.write(
key: "accessToken", value: signUpResponse.accessToken);
await storage.write(
key: "refreshToken", value: signUpResponse.refreshToken);
debugPrint(signUpResponse.accessToken);
} else {
throw Exception("Sign UP Error");
}
}
Future<String> helloRequester() async {
var helloUri = uri + "hello";
var accessToken = await storage.read(key: "accessToken");
headers["Authorization"] = accessToken ?? "";
debugPrint("send helloRequester");
final response = await http.get(Uri.parse(helloUri), headers: headers);
if (response.statusCode == 200) {
Map<String, dynamic> decoded = json.decode(response.body);
var helloResponse = HelloResponse.fromJson(decoded);
return helloResponse.message;
} else if (response.statusCode == 401 || response.statusCode == 404) {
debugPrint("send refreshTokenRequester");
await refreshTokenRequester();
var value = await helloRequester();
return value;
} else {
throw Exception("Hello Error");
}
}
Future<void> refreshTokenRequester() async {
var refreshTokenUri = uri + "auth/refresh_token";
var refreshToken = await storage.read(key: "refreshToken");
var request = RefreshTokenRequest(refreshToken: refreshToken ?? "");
final response = await http.post(Uri.parse(refreshTokenUri),
body: json.encode(request.toJson()), headers: headers);
if (response.statusCode == 200) {
Map<String, dynamic> decoded = json.decode(response.body);
var refreshTokenResponse = AuthResponse.fromJson(decoded);
await storage.write(
key: "accessToken", value: refreshTokenResponse.accessToken);
await storage.write(
key: "refreshToken", value: refreshTokenResponse.refreshToken);
debugPrint(refreshTokenResponse.accessToken);
} else {
throw Exception("Token Refresh Error");
}
}
Future<void> logoutRequester() async {
var logoutUri = uri + "auth/logout";
var accessToken = await storage.read(key: "accessToken");
headers["Authorization"] = accessToken ?? "";
final response = await http.post(Uri.parse(logoutUri), headers: headers);
if (response.statusCode == 201) {
await storage.delete(key: "accessToken");
}
}
}
class AuthResponse {
final String accessToken;
final String refreshToken;
AuthResponse.fromJson(Map<String, dynamic> json)
: accessToken = json['access_token'],
refreshToken = json['refresh_token'];
}
class AuthRequest {
final String name;
final String password;
AuthRequest({
this.name = "",
this.password = "",
});
Map<String, dynamic> toJson() => {
'password': password,
'user_name': name,
};
}
class HelloResponse {
final message;
HelloResponse.fromJson(Map<String, dynamic> json) : message = json['message'];
}
class RefreshTokenRequest {
final String refreshToken;
RefreshTokenRequest({
this.refreshToken = "",
});
Map<String, dynamic> toJson() => {
'refresh_token': refreshToken,
};
}
環境
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.5.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio (version 2020.3)
[✓] VS Code (version 1.62.3)
dependencies:
flutter:
sdk: flutter
http: ^0.13.4
flutter_secure_storage: ^5.0.2
flutter_riverpod: ^1.0.2
実装
下記の手順で、uiからstateと処理のロジックを分離していきます。
・flutter_riverpodの導入
・ProvierScopeとConsumerWighetの適用
・login.dartの変更
・hello.dartの変更
最終的なフォルダ構成は下記になります。
└─ lib
├── main.dart
├── requester
│ └── requester.dart
└── ui
├── hello
│ ├── hello.dart
│ └── hello_view_model.dart
└── login
├── login.dart
└── login_view_model.dart
flutter_riverpodの導入
まずは、flutter_riverpodをインストールします。
https://pub.dev/packages/flutter_riverpod/install
flutter pub add flutter_riverpod
provierstateの導入とConsumerWighetの適用
flutter_riverpodを使用するためにrunApp時にProviderScopeを適用します。
void main() {
runApp(ProviderScope(child: const LoginApp()));
}
次に、それぞれのuiファイルのStatefulWidgetをConsumerWidgetに変更し、Stateをextendsしていたクラスでの処理を全てConsumerWidgetに移します。
Stateを管理するメソッドは(setStateやinitStateなど)使用できないので、削除します。
最後に、buildの引数にWighetRefを追加します。
uiに対して、これらの変更を加えたコードが下記になります。
class LoginApp extends StatelessWidget {
const LoginApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ログイン画面',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: LoginPage(title: 'Login Page'),
);
}
}
class LoginPage extends ConsumerWidget {
LoginPage({Key? key, required this.title}) : super(key: key);
final String title;
final _userNameTextController = TextEditingController();
final _passwordNameTextController = TextEditingController();
final FocusNode _userNamefocusNode = FocusNode();
final FocusNode _passwordNameFocusNode = FocusNode();
var _errorMessage = "";
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_errorMessage, style: const TextStyle(color: Colors.red)),
TextField(
controller: _userNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Username',
),
focusNode: _userNamefocusNode,
),
const SizedBox(height: 12.0),
TextField(
controller: _passwordNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Password',
),
focusNode: _passwordNameFocusNode,
obscureText: true,
),
ButtonBar(
children: <Widget>[
TextButton(
child: const Text('CANCEL'),
onPressed: () {
_userNameTextController.clear();
_passwordNameTextController.clear();
},
),
ElevatedButton(
child: const Text('LOGIN'),
onPressed: () {
Requester()
.loginRequester(_userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
debugPrint(error.toString());
_userNameTextController.clear();
_passwordNameTextController.clear();
});
}),
ElevatedButton(
child: const Text('SIGNUP'),
onPressed: () {
Requester()
.signUpRequester(_userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
debugPrint(error.toString());
_userNameTextController.clear();
_passwordNameTextController.clear();
});
}),
],
),
],
),
),
);
}
}
class Hello extends ConsumerWidget {
Hello({Key? key}) : super(key: key);
var _message = "";
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("Hello"),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
TextButton(
onPressed: () {
Requester().logoutRequester().then((_) {
Navigator.pop(context);
});
},
child: const Text('LOGOUT',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16.0)),
),
],
),
body: Padding(
padding: EdgeInsets.only(top: 100),
child: Center(
child: Column(
children: [Text(_message, style: TextStyle(fontSize: 50.0))],
),
),
),
);
}
}
login.dartの変更
ここから、各uiのState管理やロジック部分をviewModelに移します。
まずは、login.dartをlogin_view_model.dartに切り出していきます。
login画面ではログイン処理に失敗した際にエラーメッセージ(errorMessage)を表示しています。
まず、これをlogin_view_model.dartに移します。
StateProvider<String> errorMessageProvider = StateProvider((ref) => '');
次に、errorMessageProviderの変更をui側で検知できるように、ConsumerWighet(LoginPage)のbuild内でwatchしておく。
class LoginPage extends ConsumerWidget {
・・・
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorMessage = ref.watch(errorMessageProvider);
・・・
ref.watch(errorMessageProvider)
を追加しておくことで、errorMessageProviderが変更すると、ui側に検知されるようになります。
次に、ログインとサインアップのapiリクエストもviewModelを経由するように変更します。
StateProvider<String> errorMessageProvider = StateProvider((ref) => '');
Future<void> loginRequest(WidgetRef ref, String name, String password) async {
return Requester()
.loginRequester(name, password)
.onError((error, stackTrace) {
ref.watch(errorMessageProvider.notifier).state = "ログインに失敗しました";
throw Exception(error.toString());
});
}
Future<void> signUpRequest(WidgetRef ref, String name, String password) async {
return Requester()
.signUpRequester(name, password)
.onError((error, stackTrace) {
ref.watch(errorMessageProvider.notifier).state = "サインアップに失敗しました";
throw Exception(error.toString());
});
}
それぞれの、request処理はui側から、ユーザー名とパスワードをもらってRequesterの処理を呼び出すだけです。
エラーになった際には、errorMessageProviderの値をref.watch(errorMessageProvider.notifier).state
で値を書き換えます。
こうすることで、ui側で変更が検知されてuiの更新が行われます。
最後に、このviewModelのrequest処理をui側で呼び出します。
ui部分の最終的なコードは下記になります。
class LoginPage extends ConsumerWidget {
LoginPage({Key? key, required this.title}) : super(key: key);
final String title;
final _userNameTextController = TextEditingController();
final _passwordNameTextController = TextEditingController();
final FocusNode _userNamefocusNode = FocusNode();
final FocusNode _passwordNameFocusNode = FocusNode();
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorMessage = ref.watch(errorMessageProvider);
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(errorMessage, style: const TextStyle(color: Colors.red)),
TextField(
controller: _userNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Username',
),
focusNode: _userNamefocusNode,
),
const SizedBox(height: 12.0),
TextField(
controller: _passwordNameTextController,
decoration: const InputDecoration(
filled: true,
labelText: 'Password',
),
focusNode: _passwordNameFocusNode,
obscureText: true,
),
ButtonBar(
children: <Widget>[
TextButton(
child: const Text('CANCEL'),
onPressed: () {
_userNameTextController.clear();
_passwordNameTextController.clear();
},
),
ElevatedButton(
child: const Text('LOGIN'),
onPressed: () {
loginRequest(ref, _userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
_userNameTextController.clear();
_passwordNameTextController.clear();
});
}),
ElevatedButton(
child: const Text('SIGNUP'),
onPressed: () {
signUpRequest(ref, _userNameTextController.text,
_passwordNameTextController.text)
.then((_) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => Hello()));
}).onError((error, stackTrace) {
_userNameTextController.clear();
_passwordNameTextController.clear();
});
}),
],
),
],
),
),
);
}
}
request内でerrorMessageProviderの更新をui側に検知するために、requestを呼び出す際に、ui側のWidgetRefを渡しています。
hello.dartの変更
次に、hello.dartのロジックをviewModelを移行させます。
まず、この画面では画面が描画せれたタイミングでapiにリクエストし、返ってきた値を表示していました。
なので、viewModelでapiリクエストを実行しレスポンスが返ってきたタイミングでuiに通知してあげます。
それを実現するために、FutureProviderを使用します。
FutureProvider<String> helloRequestProvider =
FutureProvider((ref) async {
var message = await Requester().helloRequester();
return message;
});
この、helloRequestProvider
をCosumerWighetのbuildでwatchすることで画面が描画されるタイミングでhelloRequestProvider内の処理が呼び出されます。
そして、helloRequestProviderの処理が完了した時のui側の処理をwhenで書きます。
class Hello extends ConsumerWidget {
Hello({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageProvider = ref.watch(helloRequestProvider);
・・・
body: Padding(
padding: EdgeInsets.only(top: 100),
// messageProviderでのapiリクエスト完了後の処理
child: messageProvider.when(
data: (message) => Center(
child: Column(
children: [Text(message, style: TextStyle(fontSize: 50.0))],
),
),
error: (error, stackTrace) {
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("認証に失敗しました。再ログインをお願いします。"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("OK")),
],
);
}).then((_) {
Navigator.pop(context);
});
},
loading: () => AspectRatio(
aspectRatio: 0.01,
child: const CircularProgressIndicator(),
),
whenの処理では、リクエスト成功時はレスポンスのメッセージを画面に描画しています。
エラー時にはダイアログを出して、ログイン画面に戻していますNavigator.pop(context)
。
しかし、これだとshowDialogでエラーになってしまいます。
This Overlay widget cannot be marked as needing to build because the framework is already in the process of building widgets.
これは、Wighetのbuild時に状態変更をできないという内容です。
これを回避するために、showDialogをFutureで括ります。
Future(() {
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("認証に失敗しました。再ログインをお願いします。"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("OK")),
],
);
}).then((_) {
Navigator.pop(context);
});
});
これで、画面の描画時にリクエストを送りレスポンスの内容によって、uiを変更することができました。
しかし、このままだと初回にhello画面に遷移した際はリクエストが送られますが、2回目以降はリクエストされません(ログアウト→ログインした際など)。
画面が呼び出されるたびに毎回リクエストをして欲しいので、hello画面でNavigator.pop(context)が呼ばれて画面を閉じる際にmessageProvider
を解放するようにします。
それを実現するために、FutureProvider
の代わりにAutoDisposeFutureProvider
を使用します。
こうすることで、画面が閉じるタイミングでui側のbuild時にhelloRequestProviderをwatchしていたmessageProviderが解放されます。
なので、画面再描画時もmessageProviderが再作成され、毎回処理が走るようになります。
これを踏まえた最終的なコードは下記になります。
AutoDisposeFutureProvider<String> helloRequestProvider =
FutureProvider.autoDispose((ref) async {
var message = await Requester().helloRequester();
return message;
});
Future<void> logoutRequest() async {
return Requester().logoutRequester();
}
class Hello extends ConsumerWidget {
Hello({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
debugPrint("build");
final messageProvider = ref.watch(helloRequestProvider);
return Scaffold(
appBar: AppBar(
title: Text("Hello"),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
TextButton(
onPressed: () {
logoutRequest().then((_) {
Navigator.pop(context);
});
},
child: const Text('LOGOUT',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16.0)),
),
],
),
body: Padding(
padding: EdgeInsets.only(top: 100),
child: messageProvider.when(
data: (message) => Center(
child: Column(
children: [Text(message, style: TextStyle(fontSize: 50.0))],
),
),
error: (error, stackTrace) {
Future(() {
showDialog(
context: context,
builder: (_) {
return AlertDialog(
title: Text("認証に失敗しました。再ログインをお願いします。"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text("OK")),
],
);
}).then((_) {
Navigator.pop(context);
});
});
},
loading: () => AspectRatio(
aspectRatio: 0.01,
child: const CircularProgressIndicator(),
),
),
),
);
}
}
ログアウト処理についてもリクエストをviewModel側に移しましたが大した変更ではないので、説明は省略します。
おわりに
今回は、flutter_riverpodで画面側の状態管理をviewからviewModelに移すことで、MVVMな設計でログインアプリを書き換えてみました。
今回のコードは下記になります。
https://github.com/fu-yuta/authentication_frontend/tree/mvvm
リファクタしただけだと、あまり利点が感じられなかったので、次はテストとか作りたいです。