ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter Local Storage (SQLite) 사용하기 with BLoC Pattern
    Flutter 2019. 7. 31. 16:16

    Flutter 에서 sqflite 를 사용하여 로컬 에서 데이터를 관리 해 봅시다.

    참고로 sqflite 는 flutter 에서 sqlite 의 사용을 도와주는 패키지 이름 입니다.

    기본적인 개발환경은 구성이 되어있다는 전제하에 글을 적기 때문에

    만약 flutter를 완전히 처음 접한다면 공식문서를 보고 오는 것이 좋습니다.

    새로운 프로젝트를 생성 했다면 먼저 sqflite 와 파일 경로 찾기를 도와주는 path provider 패키지를 설치합시다.

    sqflite 는 DB 제어와 쿼리, 자동 버전관리 등 을 도와주는 패키지 이며,

    path provider 는 자주 사용 되는 파일시스템 의 위치를 쉽게 찾을 수 있게 도와 줍니다.

    SQFLite: https://pub.dev/packages/sqflite

    path_provider: https://pub.dev/packages/path_provider

     

     

    두개의 패키지를 pubspec.yaml 에 추가합니다.

    ** pubspec.yaml 는 package.json 과 비슷한 dart 패키지 상태 관리 파일 입니다.

    pubspec.yaml

    패키지가 추가 되었으면 먼저 DB 를 제어하기 위한 DBHelper 클래스를 만들어 봅시다.

    // lib > services > database_helpers.dart
    
    import 'dart:io';
    
    import 'package:path_provider/path_provider.dart';
    import 'package:sqflite/sqflite.dart';
    import 'package:path/path.dart';
    
    final String tableName = 'todoExample';
    
    class DBHelper {
      DBHelper._();
    
      static final DBHelper db = DBHelper._();
    
      static Database _database;
    
      Future<Database> get database async {
        if (_database != null) return _database;
    
        _database = await initDB();
        return _database;
      }
    
      initDB() async {
        Directory documentsDirectory = await getApplicationDocumentsDirectory();
        String path = join(documentsDirectory.path, 'superAwesomeDb.db');
    
        return await openDatabase(
          path,
          version: 1,
          onCreate: (Database db, int version) async {
            await db.execute(''' 
             CREATE TABLE $tableName
               id INTEGER PRIMARY KEY,
               todo TEXT,
               type TEXT,
               complete BIT
            ''');
          },
        );
      }
    }

     

     

    DBHelper 클래스는 Flutter 에서 Local DB 에 접근하기 위한 인스턴스를 제공하며,

    동시에 초기화를 도와주는 서비스 클래스 입니다.

    Dart 에서 싱글톤 클래스를 만들기 위한 방법은 기본적으로 생성자를 private 상태로 만들고 이루어 집니다.

    추가로 static 변수를 활용한 제공 또는 별도의 getter 함수를 이용하는 방법, 마지막으로 Factory 를 이용하는 방법이 있습니다.

    위 클래스에서는 static 변수로 해당 클래스의 인스턴스를 제공 합니다.

    ** Dart 에서는 변수나 함수 앞 _ 를 붙이면 자동으로 private 상태로 인식 합니다.

     

     

    initDB() 메소드에서는 DB 의 초기화가 이루어집니다.

    path_provider 패키지의 getApplicationDocumentsDirectory() 를 이용하여 모바일 장치 내부의

    시스템파일의 경로를 찾고 DB 의 이름과 경로를 조합 합니다.

    openDatabase 에서 위에서 만든 해당 경로 와 db version 그리고 db 에서 실행 할 쿼리를 삽입 합니다.

    그리고 여기서 해당 클래스의 변수를 생성하여 외부에서 db 라는 변수로 접근하게 만들었지만

    Factory 를 사용하면 클래스 이름으로 접근 가능하게 변경이 가능합니다.

    아무래도 Flutter 에서는 클래스이름으로 접근하는게 익숙하니 이부분도 변경 해 봅시다.

     

    DBHelper._(); 
    static final DBHelper _db = DBHelper._(); 
    factory DBHelper() => _db; 

     

    기존 static 으로 지정된 변수는 private 으로 변경하고 factory 생성자를 사용하여 해당 변수를 리턴 시킵니다.

    이제부터는 DBHelper().db 로 접근이 아닌 DBHelper() 로 접근이 가능합니다.

    DB 에 접근하는 Helper 클래스를 만들었으니 테이블에 들어갈 모델 클래스를 간단하게 만들어 봅시다.

     

    // lib > models > todoModel.dart
    
    class Todo {
      int id;
      String todo;
      String type;
      bool complete;
    
      Todo({
        this.id,
        this.todo,
        this.type,
        this.complete,
      });
    
      factory Todo.fromJson(Map<String, dynamic> json) => new Todo(
            id: json["id"],
            todo: json["todo"],
            type: json["type"],
            complete: json["complete"],
          );
    
      Map<String, dynamic> toJson() => {
            "id": id,
            "todo": todo,
            "type": type,
            "complete": complete,
          };
    }

     

     

    간단한 할일 목록 클래스 입니다.

    Dart 에서 Map 형식으로 작성된 데이터는 직접적으로 DB 에 넣을 수 없습니다.

    해당 데이터를 Json 형식으로 바꿔서 넣어야 하는데 Dart는 공식적으로 Json 형식을 지원하지 않습니다.

    그래서 이를 위해 Map 형식의 데이터를 Json 으로 바꿔주는 함수를 모델 클래스에 기본적으로 생성하였습니다.

    이 부분이 정말정말 귀찮은 부분 입니다.

    Json 형식으로 받는 모든 데이터를 이런식으로 변경하여 주고 받고 해야 합니다. WTF??

    자동으로 변경시켜주는 패키지가 있을 수도 있습니다.

     

    하지만 정말 다행인 점은 별도의 패키지 도움 없이 데이터 모델을 입력하면 json 파싱 함수를 만들어주는 사이트가 있습니다.

    https://app.quicktype.io/

    dart 외 여러 언어를 다 지원하니 편하게 사용 합시다.

    모델 클래스 생성이 완성 되었다면 DB 의 CRUD 관련 함수를 작성 합시다.

     

    // CREATE
      createData(Todo todo) async {
        final db = await database;
        var res = await db.insert(tableName, todo.toJson());
        return res;
      }
    
      // READ
      getTodo(int id) async {
        final db = await database;
        var res = await db.query(tableName, where: 'id = ?', whereArgs: [id]);
        return res.isNotEmpty ? Todo.fromJson(res.first) : Null;
      }
    
      // READ ALL DATA
      getAllTodos() async {
        final db = await database;
        var res = await db.query(tableName);
        List<Todo> list =
            res.isNotEmpty ? res.map((c) => Todo.fromJson(c)).toList() : [];
        return list;
      }
    
      // Update Todo
      updateTodo(Todo todo) async {
        final db = await database;
        var res = await db.update(tableName, todo.toJson(),
            where: 'id = ?', whereArgs: [todo.id]);
        return res;
      }
    
      // Delete Todo
      deleteTodo(int id) async {
        final db = await database;
        db.delete(tableName, where: 'id = ?', whereArgs: [id]);
      }
    
      // Delete All Todos
      deleteAllTodos() async {
        final db = await database;
        db.rawDelete('Delete * from $tableName');
      }

     

    sqflite 에서 지원하는 함수로 CRUD 를 수행하고 있으며, 직접적으로 쿼리를 실행할 수 있는 함수도 존재 합니다.

    이제 간단하게 UI 를 작성 해 봅시다.

    // lib > main.dart
    
    import 'package:flutter/material.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'LocalStorageExample',
          theme: ThemeData.dark(),
          home: HomePage(),
        );
      }
    }
    
    class HomePage extends StatefulWidget {
      HomePage({Key key}) : super(key: key);
      _HomePageState createState() => _HomePageState();
    }
    
    class _HomePageState extends State<HomePage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('LOCAL CRUD'),
          ),
          body: Container(
            child: Center(
              child: Text('Center'),
            ),
          ),
        );
      }
    }

     

    첫 화면

     

     

    간단하게 메인 UI 를 작성하였습니다.

    이제 TodoTable 에 있는 데이터를 가져와서 보여주기 위해 FutureBuilder 를 생성해야 합니다.

    FutureBuildr 위젯은 지정된 비동기 요청을 처리하여 전달받은 데이터를 이용하여

    위젯을 만들고 데이터를 표현할 수 있는 위젯 입니다.

    Javascript 로 치면 Promise 와 같습니다.

    BloC 을 적용하기 전 먼저 Future Builder 로 구현 해 봅시다.

    // lib > main.dart
    
    import 'package:flutter/material.dart';
    import 'package:local_storage_example/services/db_helper.dart';
    import 'dart:math';
    
    import 'models/todoModel.dart';
    
    // 랜덤으로 들어 갈 데이터
    List<Todo> todosDatas = [
      Todo(todo: '오늘은 무슨일을 할까', type: 'TALK', complete: false),
      Todo(todo: '내일은 무슨일을 할까', type: 'MEET', complete: true),
      Todo(todo: '모레는 무슨일을 할까', type: 'TALK', complete: false),
    ];
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      ...
    }
    
    class HomePage extends StatefulWidget {
      ...
    }
    
    class _HomePageState extends State<HomePage> {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('LOCAL CRUD'),
          ),
          
          // FutureBuilder 시작
          body: FutureBuilder(
            future: DBHelper().getAllTodos(),
            builder: (BuildContext context, AsyncSnapshot<List<Todo>> snapshot) {
            
              // 데이터의 유무를 확인한다
              return snapshot.hasData
                  ? ListView.builder(
                      itemCount: snapshot.data.length,
                      itemBuilder: (BuildContext context, int index) {
                        Todo item = snapshot.data[index];
                        return Dismissible(
                          key: UniqueKey(),
                          onDismissed: (direction) {
                            DBHelper().deleteTodo(item.id);
                          },
                          child: ListTile(
                            title: Text(item.todo),
                            leading: Text(
                              item.id.toString(),
                            ),
                            trailing: Checkbox(
                              onChanged: (bool value) {
                                Todo updateData = Todo(
                                    complete: !item.complete,
                                    todo: item.todo,
                                    type: item.type,
                                    id: item.id);
                                DBHelper().updateTodo(updateData);
                                setState(() {});
                              },
                              value: item.complete,
                            ),
                          ),
                        );
                      },
                    )
                  : Center(
                      child: CircularProgressIndicator(),
                    );
            },
          ),
          
          // 전체 삭제와 TODO 추가를 위한 FloatingActionButton
          
          floatingActionButton: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              FloatingActionButton(
                child: Icon(Icons.remove),
                onPressed: () {
                  DBHelper().deleteAllTodos();
                  setState(() {});
                },
              ),
              SizedBox(
                height: 16.0,
              ),
              FloatingActionButton(
                child: Icon(Icons.add),
                onPressed: () {
                  Todo newTodo = todosDatas[Random().nextInt(todosDatas.length)];
                  DBHelper().createData(newTodo);
                  setState(() {});
                },
              ),
            ],
          ),
        );
      }
    }

     

    Future Builder 의 future 에는 getAllTodos() 를 지정하고,

    builder 부분을 보면 context 와 AsyncSnapshot 을 파라미터로 받고 있습니다.

    이 snapshot 이 지정된 Future 에서 비동기로 받아온 데이터를 나타냅니다.

    snapshot.hasData 로 데이터가 있다면 해당 데이터를 나타내는 ListVIew builder 를 사용하고,

    데이터가 존재하지 않는다면 LodingIndicator 를 나타나게 하였습니다.

     

    추가로 FloatingActionButton 2개를 만들어 모든 데이터를 지우거나, 추가하는 함수를 실행하도록 하였습니다.

    새로운 데이터 입력화면이 없으므로 코드 상단에 샘플 데이터를 두고, 추가 버튼을 클릭할때 랜덤 으로 데이터가

    들어가도록 만들었습니다.

    List View 내부에 있는 Dismissible 위젯은 아이폰 잠금화면에서 볼 수 있는 밀어서 잠금해제 와 같은 위젯 입니다.

    위 코드에서는 밀어서 데이터 삭제가 되도록 만들었습니다.

     

    실행 화면

     

    데이터 추가, 삭제를 모두 정상적으로 작동하는 것을 확인하였습니다.

    Future Builder 로도 충분히 앱을 만들 수 있다. 하지만 이 글에서는 Stream Builder 관련 예제도 같이 해 볼 것 입니다.

    Reactive Programming 에 대해서 숙지 하는 것이 좋습니다.

    만약 처음 접한다면 stream 기반으로 데이터를 조작하는 것에 있어서 혼란 스럽게 느껴 질 수 있습니다.

     

    Reactive Programming 의 장점으로는

    https://medium.com/corebuild-software/why-you-should-learn-reactive-programming-51b6ffc31425

    위 글을 읽어봅시다.

    RX 관련 라이브러리가 Stream 기반 로직을 쉽게 작성하도록 도와주는 대표적인 라이브러리 입니다.

    Stream Builder 만 사용해도 되며, BloC 패턴을 같이 사용해도 됩니다.

    Future Builder 에서 Stream Builder 로 변경하려면 Stream Controller 를 추가하며 약간의 코드변경 이면 가능 합니다.

    이제  Bloc 패턴을 적용 해 봅시다.

     

     

     

     

     

    BloC (Business Logic Component)

    간단한 특징으로는

     

    • 하나 또는 여러개의 Bloc 이 존재 할 수 있다.
    • Presentation Layer 에서 비즈니스 로직을 최대한 분리하며, 그로 인해 그로인해 테스트가 용이하다.
    • 플랫폼 종속적이지 않다.
    • 환경에 종속적이지 않다.

     

    View 에서 비즈니스 로직을 최대한 분리하고, Bloc 에서 데이터 처리를 담당하고

    해당 데이터가 필요한 View 에서는 해당 stream 을 구독하여 데이터를 표시하고,

    데이터의 변경이 필요할 때는 해당 Stream 으로 Sink 하여 데이터를 변경 합니다.

    변경된 데이터는 해당 Stream 을 구독하고 있는 모든 View 에 전달 됩니다.

    Dart 에서 RxDart 를 사용할 수 있지만, 기본적으로 다트는 Stream 을 지원하기 때문에

    기본 제공되는 Stream 으로 구현 해 봅시다.

    먼저 비즈니스 로직을 분리해서 BloC 파일을 만듭니다.

    // lib > bloc > todo_bloc.dart
    
    import 'dart:async';
    import 'package:local_storage_example/models/todoModel.dart';
    import 'package:local_storage_example/services/db_helper.dart';
    
    class TodoBloc {
      TodoBloc() {
        getTodos();
      }
    
      final _todosController = StreamController<List<Todo>>.broadcast();
      get todos => _todosController.stream;
    
      dispose() {
        _todosController.close();
      }
    
      getTodos() async {
        _todosController.sink.add(await DBHelper().getAllTodos());
      }
    
      addTodos(Todo todo) async {
        await DBHelper().createData(todo);
        getTodos();
      }
    
      deleteTodo(int id) async {
        await DBHelper().deleteTodo(id);
        getTodos();
      }
    
      deleteAll() async {
        await DBHelper().deleteAllTodos();
        getTodos();
      }
    
      updateTodo(Todo todo) async {
        await DBHelper().updateTodo(todo);
        getTodos();
      }
    }

     

     

    View 에서 해당 Bloc 의 데이터를 구독하기 위한 StreamController 를생성하고, broadcast() 를 해줍니다.

    broadcast() 는 일반적으로 SingleSubscription 으로 생성되는 Controller 를 Multi Subscription 으로 변경 합니다.

    한마디로 기본적으로 stream 을구독하여 데이터를 전파 받을 수 있는 건 하나지만,

    이걸 여러 곳 에서 전파 받을 수 있도록변경 시킨 것 입니다.

     

    BloC 을 사용할 때 주의 할 점 중 하나는 dispose() 에서 stream 을 닫는 로직을 지정하고,

    BloC 을 사용하는 곳에서 사용하지 않는 stream controller 는 꼭 close() 해줍시다.

    구독 취소를 꼭 해주자는 뜻 입니다.

    그러기 위해 lifeCycle 제어가 가능한 Stateful 위젯을 사용하는게 좋습니다.

    이제 main.dart 에서 bloc 를 사용하여 데이터를 처리하도록 변경 해봅시다.

    // lib > main.dart
    
    import 'package:flutter/material.dart';
    import 'dart:math';
    import 'models/todoModel.dart';
    import 'bloc/todo_bloc.dart';
    
    ...
    
    class MyApp extends StatelessWidget {
      ...
    }
    
    class HomePage extends StatefulWidget {
     ...
    }
    
    class _HomePageState extends State<HomePage> {
      // Bloc 을 가져옵시다.
      final TodoBloc bloc = TodoBloc();
    
      // Stateful Widget 이 dispose 될때 스트림을 닫는다.
      @override
      void dispose() {
        super.dispose();
        bloc.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('LOCAL CRUD'),
          ),
          
          // FutureBuilder 를 StreamBuilder 로 변경한다.
          body: StreamBuilder(
            stream: bloc.todos,
            builder: (BuildContext context, AsyncSnapshot<List<Todo>> snapshot) {
              return snapshot.hasData
                  ? ListView.builder(
                      itemCount: snapshot.data.length,
                      itemBuilder: (BuildContext context, int index) {
                        Todo item = snapshot.data[index];
                        return Dismissible(
                          key: UniqueKey(),
                          onDismissed: (direction) {
                            // 이제 Bloc 을 이용하여 명령을 수행합니다.
                            bloc.deleteTodo(item.id);
                          },
                          child: ListTile(
                            title: Text(item.todo),
                            leading: Text(
                              item.id.toString(),
                            ),
                            trailing: Checkbox(
                              onChanged: (bool value) {
                             // 이제 Bloc 을 이용하여 명령을 수행합니다.
                                bloc.updateTodo(item);
                              },
                              value: item.complete,
                            ),
                          ),
                        );
                      },
                    )
                  : Center(
                      child: Center(
                        child: Text('No data'),
                      ),
                    );
            },
          ),
          floatingActionButton: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              FloatingActionButton(
                child: Icon(Icons.remove),
                onPressed: () {
                // 이제 Bloc 을 이용하여 명령을 수행합니다.
                  bloc.deleteAll();
                },
              ),
              SizedBox(
                height: 16.0,
              ),
              FloatingActionButton(
                child: Icon(Icons.add),
                onPressed: () {
                  Todo newTodo = todosDatas[Random().nextInt(todosDatas.length)];
                  // 이제 Bloc 을 이용하여 명령을 수행합니다.
                  bloc.addTodos(newTodo);
                },
              ),
            ],
          ),
        );
      }
    }

     

    FutureBuilder 가 StreamBuilder 로 변경되고, 위젯에서 사용할 Bloc 을 가져 와서 사용합니다.

    이제 BloC 이 모든 비즈니스 로직을 처리하게 되었고,

    View 와 비즈니스 로직을 분리 했습니다. 해당 BloC 이 필요한 곳에서만 BloC 을 가져다 쓰면 됩니다.

     

    BLOC 변경 후 실행

     

     

    해당 예제 전체 소스코드는 아래서 볼 수 있습니다.

    Full source code : https://github.com/SiWonRyu/flutter_sqflite_example

     

    댓글