Top

Up

Flutter Login Tutorial

원문: Bloc / Tutorial / Login

intermediate

이 튜토리얼에서는, Flutter에서 Bloc 라이브러리를 사용하여 Login Flow를 빌드 하겠습니다.

demo

준비 (Setup)

새 브랜드의 프로젝트를 생성하며 시작하겠습니다.

flutter create flutter_login

pubspec.yaml의 내용을 변경하여 계속 진행합니다.

name: flutter_login
description: A new Flutter project.
version: 1.0.0+1

environment:
  sdk: ">=2.0.0-dev.68.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^0.13.0
  meta: ^1.1.6
  equatable: ^0.2.0

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

그리고, 의존하는 패키지들을 설치합니다.

flutter packages get

사용자 저장소 (User Repository)

사용자 정보를 관리하기 위한 UserRepository를 생성할 필요가 있습니다.

class UserRepository {
  Future<String> authenticate({
    @required String username,
    @required String password,
  }) async {
    await Future.delayed(Duration(seconds: 1));
    return 'token';
  }

  Future<void> deleteToken() async {
    /// delete from keystore/keychain
    await Future.delayed(Duration(seconds: 1));
    return;
  }

  Future<void> persistToken(String token) async {
    /// write to keystore/keychain
    await Future.delayed(Duration(seconds: 1));
    return;
  }

  Future<bool> hasToken() async {
    /// read from keystore/keychain
    await Future.delayed(Duration(seconds: 1));
    return false;
  }
}

Note: 우리의 사용자 저장소는 단순화를 위해 모든 다른 구현을 모사(mock)하고 있지만 실제 응용 프로그램에서는 HttpClientFlutter Secure Store를 사용하여 토큰을 요청하고 키스토어/키체인에 읽고 쓸 수 있습니다.

인증 상태 (Authentication State)

다음으로 우리는 애플리케이션 상태를 어떻게 관리하고 필요한 Bloc (Business Logic Component)을 만들지 결정해야 할 것입니다.

높은 수준에서 우리는 사용자의 인증 상태를 관리해야 할 것입니다. 사용자의 인증 상태는 다음 중 하나 일 수 있습니다.

이 각 상태들은 사용자가 보는 것에 대해 의미를 나태내야 할 것입니다.

예를 들어:

예를 들어:

구현에 들어가기 전에 다른 상태들에 무엇인지 파악하는 것이 중요합니다.

인증 상태가 식별되었으므로 이제 우리는 AuthenticationState 클래스를 구현할 수 있습니다.

import 'package:equatable/equatable.dart';

abstract class AuthenticationState extends Equatable {}

class AuthenticationUninitialized extends AuthenticationState {
  @override
  String toString() => 'AuthenticationUninitialized';
}

class AuthenticationAuthenticated extends AuthenticationState {
  @override
  String toString() => 'AuthenticationAuthenticated';
}

class AuthenticationUnauthenticated extends AuthenticationState {
  @override
  String toString() => 'AuthenticationUnauthenticated';
}

class AuthenticationLoading extends AuthenticationState {
  @override
  String toString() => 'AuthenticationLoading';
}

Note: [equatable(https://pub.dartlang.org/packages/equatable) 패키지는 AuthenticationState의 두 인스턴스를 비교할 수 있게 하기 위해 사용됩니다. 기본적으로 ==는 두 객체가 같은 인스턴스인 경우에만 true를 반환합니다.

Note: toString은 콘솔이나 Transitions에 출력 할 때 AuthenticationState를 읽기 쉽게 하기 위해 무시됩니다.

AuthenticationEvent

이제 우리는 우리의 AuthenticationState를 정의했습니다. 우리는 AuthenticationBloc이 반응 할AuthenticationEvents를 정의 할 필요가 있습니다.

우리가 필요한 것은:

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

abstract class AuthenticationEvent extends Equatable {
  AuthenticationEvent([List props = const []]) : super(props);
}

class AppStarted extends AuthenticationEvent {
  @override
  String toString() => 'AppStarted';
}

class LoggedIn extends AuthenticationEvent {
  final String token;

  LoggedIn({@required this.token}) : super([token]);

  @override
  String toString() => 'LoggedIn { token: $token }';
}

class LoggedOut extends AuthenticationEvent {
  @override
  String toString() => 'LoggedOut';
}

Note: meta 패키지는 AuthenticationEvent 매개 변수에 @required로 주석을 달기 위해 사용됩니다. 이렇게 하면 Dart 분석기(analyzer)가 필요한 매개 변수를 제공하지 않으면 개발자에게 경고합니다.

AuthenticationBloc

이제 우리는 AuthenticationStateAuthenticationEvents를 정의 했으므로AuthenticationEvents에 대한 응답으로 사용자의 AuthenticationState를 검사하고 업데이트하는AuthenticationBloc을 구현할 수 있습니다.

우리는 AuthenticationBloc 클래스를 작성하는 것으로 시작합니다.

class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
  final UserRepository userRepository;

  AuthenticationBloc({@required this.userRepository}): assert(userRepository != null);
}

Note: 클래스 정의를 읽는 것에서부터, 우리는 이 블록이 AuthenticationEventsAuthenticationStates로 변환 할 것이라는 것을 이미 알고 있습니다.

Note: 우리의 AuthenticationBlocUserRepository에 의존합니다.

우리는initialStateAuthenticationUninitialized()상태로 재정의하여 시작할 수 있습니다.

@override
AuthenticationState get initialState => AuthenticationUninitialized();

이제 남겨진 것은 mapEventToState를 구현하는 것입니다.

@override
Stream<AuthenticationState> mapEventToState(
  AuthenticationEvent event,
) async* {
  if (event is AppStarted) {
    final bool hasToken = await userRepository.hasToken();

    if (hasToken) {
      yield AuthenticationAuthenticated();
    } else {
      yield AuthenticationUnauthenticated();
    }
  }

  if (event is LoggedIn) {
    yield AuthenticationLoading();
    await userRepository.persistToken(event.token);
    yield AuthenticationAuthenticated();
  }

  if (event is LoggedOut) {
    yield AuthenticationLoading();
    await userRepository.deleteToken();
    yield AuthenticationUnauthenticated();
  }
}

훌륭합니다! 우리의 마지막 AuthenticationBloc은 다음과 같이 보일 것입니다.

import 'dart:async';

import 'package:meta/meta.dart';
import 'package:bloc/bloc.dart';
import 'package:user_repository/user_repository.dart';

import 'package:flutter_login/authentication/authentication.dart';

class AuthenticationBloc
    extends Bloc<AuthenticationEvent, AuthenticationState> {
  final UserRepository userRepository;

  AuthenticationBloc({@required this.userRepository})
      : assert(userRepository != null);

  @override
  AuthenticationState get initialState => AuthenticationUninitialized();

  @override
  Stream<AuthenticationState> mapEventToState(
    AuthenticationEvent event,
  ) async* {
    if (event is AppStarted) {
      final bool hasToken = await userRepository.hasToken();

      if (hasToken) {
        yield AuthenticationAuthenticated();
      } else {
        yield AuthenticationUnauthenticated();
      }
    }

    if (event is LoggedIn) {
      yield AuthenticationLoading();
      await userRepository.persistToken(event.token);
      yield AuthenticationAuthenticated();
    }

    if (event is LoggedOut) {
      yield AuthenticationLoading();
      await userRepository.deleteToken();
      yield AuthenticationUnauthenticated();
    }
  }
}

이제 우리는 AuthenticationBloc을 완전히 구현 했으므로, 이제는 프리젠테이션 레이어를 작업 해 보겠습니다.

SplashPage

우선 우리가 필요로 하는 것은 SplashPage 위젯입니다. 우리의 AuthenticationBloc은 사용자가 로그인했는지 여부를 결정하는 동안 우리의 스플래쉬 화면으로 사용됩니다.

import 'package:flutter/material.dart';

class SplashPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Splash Screen'),
      ),
    );
  }
}

HomePage

다음으로, 성공적으로 로그인 한 사용자를 탐색 할 수 있도록 HomePage를 생성해야 합니다.

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';

import 'package:flutter_login/authentication/authentication.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final AuthenticationBloc authenticationBloc =
        BlocProvider.of<AuthenticationBloc>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
      ),
      body: Container(
        child: Center(
            child: RaisedButton(
          child: Text('logout'),
          onPressed: () {
            authenticationBloc.dispatch(LoggedOut());
          },
        )),
      ),
    );
  }
}

Note: 이것은 flutter_bloc을 사용하는 첫 번째 클래스입니다. BlocProvider.of<AuthenticationBloc> (context)에 곧 들어가 겠지만, 이제는 우리의 HomePage가 우리의 AuthenticationBloc에 액세스 할 수 있다는 것을 알고 있습니다.

Note: 사용자가 로그 아웃 버튼을 눌렀을 때 AuthenticationBlocLoggedOut 이벤트를 보냅니다.

다음으로, 우리는 Login PageLoginForm을 생성 할 필요가 있습니다.

LoginForm은 사용자 입력(Login Button Pressed)을 처리해야하고 비즈니스 로직 (주어진 사용자 이름 / 암호에 대한 토큰을 가져와야 함)이 필요하기 때문에, 우리는 LoginBloc을 생성해야 합니다.

AuthenticationBloc처럼 우리는 LoginStateLoginEvents를 정의 할 필요가 있습니다. LoginState를 시작 하겠습니다.

LoginState

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

abstract class LoginState extends Equatable {
  LoginState([List props = const []]) : super(props);
}

class LoginInitial extends LoginState {
  @override
  String toString() => 'LoginInitial';
}

class LoginLoading extends LoginState {
  @override
  String toString() => 'LoginLoading';
}

class LoginFailure extends LoginState {
  final String error;

  LoginFailure({@required this.error}) : super([error]);

  @override
  String toString() => 'LoginFailure { error: $error }';
}

LoginInitialLoginForm의 초기 상태 입니다.

LoginLoading은 자격 증명의 유효성(credentials)을 검사(validating) 할 때 LoginForm의 상태입니다.

LoginFailure는 로그인 시도가 실패했을 때 LoginForm의 상태입니다.

이제LoginState가 정의되었으므로 LoginEvent 클래스를 살펴 보겠습니다.

LoginEvent

import 'package:meta/meta.dart';
import 'package:equatable/equatable.dart';

abstract class LoginEvent extends Equatable {
  LoginEvent([List props = const []]) : super(props);
}

class LoginButtonPressed extends LoginEvent {
  final String username;
  final String password;

  LoginButtonPressed({
    @required this.username,
    @required this.password,
  }) : super([username, password]);

  @override
  String toString() =>
      'LoginButtonPressed { username: $username, password: $password }';
}

LoginButtonPressed는 사용자가 로그인 버튼을 눌렀을 때 전달됩니다. LoginBloc에게 주어진 자격증명을위한 토큰을 요청할 필요가 있음을 알릴 것입니다.

이제 우리는 LoginBloc을 구현할 수 있습니다.

LoginBloc

import 'dart:async';

import 'package:meta/meta.dart';
import 'package:bloc/bloc.dart';
import 'package:user_repository/user_repository.dart';

import 'package:flutter_login/authentication/authentication.dart';
import 'package:flutter_login/login/login.dart';

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final UserRepository userRepository;
  final AuthenticationBloc authenticationBloc;

  LoginBloc({
    @required this.userRepository,
    @required this.authenticationBloc,
  })  : assert(userRepository != null),
        assert(authenticationBloc != null);

  LoginState get initialState => LoginInitial();

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is LoginButtonPressed) {
      yield LoginLoading();

      try {
        final token = await userRepository.authenticate(
          username: event.username,
          password: event.password,
        );

        authenticationBloc.dispatch(LoggedIn(token: token));
        yield LoginInitial();
      } catch (error) {
        yield LoginFailure(error: error.toString());
      }
    }
  }
}

Note: LoginBloc은 사용자 이름과 패스워드를 가진 사용자를 인증하기 위해 UserRepository에 의존합니다.

Note: LoginBloc has a dependency on AuthenticationBloc in order to update the AuthenticationState when a user has entered valid credentials.

LoginBloc는 사용자가 유효한 인증서를 입력했을 때 AuthenticationState를 업데이트하기 위해AuthenticationBloc에 의존합니다.

이제 우리는 LoginBloc을 가지고 LoginPageLoginForm에 대한 작업을 시작할 수 있습니다.

LoginPage

LoginPage 위젯은 컨테이너 위젯으로 사용되며 LoginForm 위젯에 필요한 의존성을 제공합니다 (LoginBlocAuthenticationBloc).

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:user_repository/user_repository.dart';

import 'package:flutter_login/authentication/authentication.dart';
import 'package:flutter_login/login/login.dart';

class LoginPage extends StatefulWidget {
  final UserRepository userRepository;

  LoginPage({Key key, @required this.userRepository})
      : assert(userRepository != null),
        super(key: key);

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  LoginBloc _loginBloc;
  AuthenticationBloc _authenticationBloc;

  UserRepository get _userRepository => widget.userRepository;

  @override
  void initState() {
    _authenticationBloc = BlocProvider.of<AuthenticationBloc>(context);
    _loginBloc = LoginBloc(
      userRepository: _userRepository,
      authenticationBloc: _authenticationBloc,
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Login'),
      ),
      body: LoginForm(
        authenticationBloc: _authenticationBloc,
        loginBloc: _loginBloc,
      ),
    );
  }

  @override
  void dispose() {
    _loginBloc.dispose();
    super.dispose();
  }
}

Note: LoginPageStatefulWidget입니다. LoginPage 위젯은 상태의 일부로 LoginBloc을 생성하고 그것을 처리합니다.

Note: 우리는 LoginBloc을 만들기 위해 삽입 된 UserRepository를 사용하고 있습니다.

Note: 우리는LoginPage에서 AuthenticationBloc에 접근하기 위해 다시 BlocProvider.of <AuthenticationBloc>(context)를 사용하고 있습니다.

다음으로, 우리의 LoginForm을 만들어 보겠습니다.

LoginForm

import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';

import 'package:flutter_login/authentication/authentication.dart';
import 'package:flutter_login/login/login.dart';

class LoginForm extends StatefulWidget {
  final LoginBloc loginBloc;
  final AuthenticationBloc authenticationBloc;

  LoginForm({
    Key key,
    @required this.loginBloc,
    @required this.authenticationBloc,
  }) : super(key: key);

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();

  LoginBloc get _loginBloc => widget.loginBloc;

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<LoginEvent, LoginState>(
      bloc: _loginBloc,
      builder: (
        BuildContext context,
        LoginState state,
      ) {
        if (state is LoginFailure) {
          _onWidgetDidBuild(() {
            Scaffold.of(context).showSnackBar(
              SnackBar(
                content: Text('${state.error}'),
                backgroundColor: Colors.red,
              ),
            );
          });
        }

        return Form(
          child: Column(
            children: [
              TextFormField(
                decoration: InputDecoration(labelText: 'username'),
                controller: _usernameController,
              ),
              TextFormField(
                decoration: InputDecoration(labelText: 'password'),
                controller: _passwordController,
                obscureText: true,
              ),
              RaisedButton(
                onPressed:
                    state is! LoginLoading ? _onLoginButtonPressed : null,
                child: Text('Login'),
              ),
              Container(
                child:
                    state is LoginLoading ? CircularProgressIndicator() : null,
              ),
            ],
          ),
        );
      },
    );
  }

  void _onWidgetDidBuild(Function callback) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      callback();
    });
  }

  _onLoginButtonPressed() {
    _loginBloc.dispatch(LoginButtonPressed(
      username: _usernameController.text,
      password: _passwordController.text,
    ));
  }
}

Note: 우리의 LoginForm은 새로운 LoginState가 있을 때마다 다시 빌드 할 수 있도록 BlocBuilder 위젯을 사용합니다. BlocBuilder는 Block과 builder 함수를 필요로 하는 Flutter 위젯입니다. BlocBuilder는 새로운 상태에 대한 응답으로 위젯을 구현합니다. BlocBuilderStreamBuilder와 매우 비슷하지만 필요한 보일러플레이트 코드(boilerplate code; 관용 코드)의 양과 다양한 성능 최적화를 줄이는 더 간단한 API를 가지고 있습니다.

LoginForm 위젯에 그다지 많은 부분이 없으므로 로딩 인디케이터를 만드는 단계로 넘어 가겠습니다.

LoadingIndicator

import 'package:flutter/material.dart';

class LoadingIndicator extends StatelessWidget {
  @override
  Widget build(BuildContext context) => Center(
        child: CircularProgressIndicator(),
      );
}

이제 마침내 모든 것을 정리하고 main.dart에 메인 앱 위젯을 만듭니다.

Putting it all together

import 'package:flutter/material.dart';

import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:user_repository/user_repository.dart';

import 'package:flutter_login/authentication/authentication.dart';
import 'package:flutter_login/splash/splash.dart';
import 'package:flutter_login/login/login.dart';
import 'package:flutter_login/home/home.dart';
import 'package:flutter_login/common/common.dart';

class SimpleBlocDelegate extends BlocDelegate {
  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print(transition);
  }
}

void main() {
  BlocSupervisor().delegate = SimpleBlocDelegate();
  runApp(App(userRepository: UserRepository()));
}

class App extends StatefulWidget {
  final UserRepository userRepository;

  App({Key key, @required this.userRepository}) : super(key: key);

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  AuthenticationBloc authenticationBloc;
  UserRepository get userRepository => widget.userRepository;

  @override
  void initState() {
    authenticationBloc = AuthenticationBloc(userRepository: userRepository);
    authenticationBloc.dispatch(AppStarted());
    super.initState();
  }

  @override
  void dispose() {
    authenticationBloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider<AuthenticationBloc>(
      bloc: authenticationBloc,
      child: MaterialApp(
        home: BlocBuilder<AuthenticationEvent, AuthenticationState>(
          bloc: authenticationBloc,
          builder: (BuildContext context, AuthenticationState state) {
            if (state is AuthenticationUninitialized) {
              return SplashPage();
            }
            if (state is AuthenticationAuthenticated) {
              return HomePage();
            }
            if (state is AuthenticationUnauthenticated) {
              return LoginPage(userRepository: userRepository);
            }
            if (state is AuthenticationLoading) {
              return LoadingIndicator();
            }
          },
        ),
      ),
    );
  }
}

Note: 다시, 우리는 AuthenticationState의 변화에 반응하기 위해 BlocBuilder를 사용하고 있습니다. 그래서 현재의 AuthenticationState를 기반으로 사용자가 SplashPage, LoginPage, HomePage 또는LoadingIndicator를 보여줄 수 있습니다.

Note: 우리의 앱에는 AuthenticationBloc이 주입되어 BlocProvider 위젯을 사용하여 전체 위젯 하위 트리에 사용할 수 있습니다. BlocProviderBlocProvider.of(context)를 통해 자식에게 Bloc을 제공하는 Flutter 위젯입니다. 의존성 삽입(dependency injection; DI) 위젯으로 사용되어 bloc의 단일 인스턴스가 서브 트리 내의 여러 위젯에 제공 될 수 있습니다.

이제 여러분의 HomePageLogin Page 위젯의 BlocProvider.of<AuthenticationBloc>(context)은 의미가 있습니다.

우리가 MaterialAppBlocProvider<AuthenticationBloc>에 래핑하였으므로 서브 트리의 어디서나 BlocProvider.of<AuthenticationBloc>(BuildContext context)정적 메소드를 사용하여AuthenticationBloc의 인스턴스에 접근 할 수 있습니다.

이 시점에서 우리는 꽤 단단한 로그인 구현을 가지고 있으며 우리는 Bloc을 사용하여 비즈니스 로직 레이어에서 프리젠 테이션 레이어를 분리했습니다.

전체 소스 코드는 이곳에서 볼 수 있습니다.


이전: 무한 리스트 (Infinite List)

다음: 날씨 (Weather)