FLUTTER CLEAN ARCHITECTURE (PART 14) – Giao diện

Trần Đình Quý

Flutter Clean Architecture

Giao diện là thứ cuối cùng để làm mọi thứ mà chúng ta đã làm trước đây hoạt động. Phần này sẽ về việc dựng lên UI và chia nó thành các widget nhỏ có thể đọc được code.

Tạo page

Chức năng number trivia sẽ có 1 page duy nhất ở trên app, nó sẽ là StatelessWidget và được gọi là NumberTriviaPage. Hãy tạo 1 page tạm thời cho nó.

…/presentation/pages/number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
    );
  }
}

All this time the main.dart file contained the example counter app. Let’s change that! We’re going to delete everything and create pristine MaterialApp with some basic green (Reso Coder style ?) theming. Of course, the home widget will be the NumberTriviaPage.

Khi tạo dự án ban đầu thì main.dart sẽ luôn ban gồm example về counter app. Chúng ta sẽ xoá mọi thứ và tạo 1 Material App với màn hình đơn giản với theme. Tất nhiên home widget sẽ là NumberTriviaPage.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Number Trivia',
      theme: ThemeData(
        primaryColor: Colors.green.shade800,
        accentColor: Colors.green.shade600,
      ),
      home: NumberTriviaPage(),
    );
  }
}

Tạo Presentation logic holder

Trong clean architecture, con đường giao tiếp duy nhất giữa các tiện ích UI và phần còn lại của ứng dụng là presentation logic holder. Ứng dụng Number Trivia sử dụng Bloc, nhưng như tôi đã nói, bạn có thể sử dụng bất kỳ thứ gì từ. Bất kể phương pháp quản lý trạng thái ưa thích của bạn là gì, bạn vẫn cần cung cấp presentation logic holder của mình trên toàn bộ cây widget.

Để làm được điều đó, rõ ràng bạn có thể sử dụng provider package! Vì chúng tôi đang sử dụng Bloc được tích hợp với nhà cung cấp nên chúng tôi sẽ sử dụng BlocProvider đặc biệt có một số tính năng thú vị dành riêng cho Bloc. NumberTriviaBloc phải có sẵn cho toàn bộ phần Scaffold của trang. Đây là nơi chúng ta sẽ lấy phiên bản NumberTriviaBloc đã đăng ký từ locator, phiên bản này sẽ lần lượt khởi động tất cả các lazy singleton đã đăng ký trong phần trước.

number_trivia_page.dart

...
import '../../../../injection_container.dart';
import '../bloc/bloc.dart';

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: BlocProvider(
        builder: (_) => sl<NumberTriviaBloc>(),
        child: Container(),
      ),
    );
  }
}

Nội dung data

Trước khi phản ứng với các state do NumberTriviaBloc phát ra, trước tiên chúng ta hãy xây dựng một phác thảo giao diện người dùng cơ bản bằng cách sử dụng PlaceHolder.

Phần gốc của UI sẽ là một Column được thêm padding và căn giữa, gồm hai phần cơ bản:

Nửa trên sẽ liên quan đến đầu ra. Sẽ có một thông báo chứa chính câu đố đó, một loại lỗi nào đó hoặc thậm chí là lệnh gọi ban đầu để “Bắt đầu tìm kiếm!”. LoadingIndicator cũng sẽ được hiển thị ở phần trên cùng. Phần UI này sẽ được build lại bất cứ khi nào trạng thái thay đổi.
Phần dưới cùng sẽ xử lý đầu vào. Nó sẽ chứa một TextField và hai ElevatedButton.

Outlining với Placeholders

Thiết kế bố cục UI với placeholder là một cách hoàn hảo để đặt khoảng cách và kích thước của các widget một cách thích hợp mà không cần phải suy nghĩ về việc triển khai chúng. Tại thời điểm này, cách tốt nhất là trích xuất ​ ​phần thân của Scaffold​ vào phương thức buildBody helper của chính nó.

number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: buildBody(context),
    );
  }

  BlocProvider<NumberTriviaBloc> buildBody(BuildContext context) {
    return BlocProvider(
      builder: (_) => sl<NumberTriviaBloc>(),
      child: Center(
        child: Padding(
          padding: const EdgeInsets.all(10),
          child: Column(
            children: <Widget>[
              SizedBox(height: 10),
              // Top half
              Container(
                // Third of the size of the screen
                height: MediaQuery.of(context).size.height / 3,
                // Message Text widgets / CircularLoadingIndicator
                child: Placeholder(),
              ),
              SizedBox(height: 20),
              // Bottom half
              Column(
                children: <Widget>[
                  // TextField
                  Placeholder(fallbackHeight: 40),
                  SizedBox(height: 10),
                  Row(
                    children: <Widget>[
                      Expanded(
                        // Search concrete button
                        child: Placeholder(fallbackHeight: 30),
                      ),
                      SizedBox(width: 10),
                      Expanded(
                        // Random button
                        child: Placeholder(fallbackHeight: 30),
                      )
                    ],
                  )
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

Giao diện nửa bên trên – Hiển thị dữ liệu

Nửa trên của màn hình phải hiển thị các widget khác nhau tùy thuộc vào state xuất ra từ NumberTriviaBloc. Quá Loading sẽ hiển thị progress indicator, Tất nhiên, Error sẽ hiển thị thông báo lỗi, v.v. Có thể xây dựng các widget khác nhau theo state hiện tại của bloc bằng BlocBuilder.

State ban đầu của NumberTriviaBloc là Empty, vì vậy trước tiên hãy xử lý trạng thái đó bằng cách trả về một text đơn giản được bao bọc bên trong Container để đảm bảo nó chiếm một phần ba chiều cao màn hình.

number_trivia_page.dart

..
// Top half
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return Container(
        // Third of the size of the screen
        height: MediaQuery.of(context).size.height / 3,
        child: Center(
          child: Text('Start searching!'),
        ),
      );
    }
    // We're going to also check for the other states
  },
),
...

Hiển thị message

Ngoài văn bản nhỏ, trạng thái Empty cũng được xử lý. Tuy nhiên, trước tiên hãy dọn dẹp mớ hỗn độn mà chúng ta đã tạo bằng cách extract Container vào tiện ích MessageDisplay của riêng nó. ta cũng sẽ sử dụng tiện ích này để hiển thị các thông báo lỗi khi trạng thái Error được phát ra, vì vậy chúng ta phải đảm bảo rằng thông báo String có thể được chuyển qua constructor.

Ngoài ra, chúng tôi sẽ thêm một chút style để làm cho text lớn hơn và cũng có thể cuộn được bằng SingleChildScrollView. Nếu không, các tin nhắn dài sẽ bị cắt mất.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    }
  },
),

...

class MessageDisplay extends StatelessWidget {
  final String message;

  const MessageDisplay({
    Key key,
    @required this.message,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      // Third of the size of the screen
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: SingleChildScrollView(
          child: Text(
            message,
            style: TextStyle(fontSize: 25),
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

Việc tái sử dụng MessageDisplay này cho các thông báo từ Error state là điều hoàn toàn tự nhiên. Chúng ta sẽ mở rộng BlocBuilder với một điều kiện else if bổ sung:

number_trivia_page.dart

BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),

LoadingWidget

Lấy dữ liệu từ API từ xa mất một khoảng thời gian. Đó là lý do Bloc phát ra trạng thái Loading và trách nhiệm của UI là hiển thị một loading indicator. Chúng ta đã biết rằng việc đặt các widget dài dòng trực tiếp vào BlocBuilder làm giảm tính đọc hiểu, vì vậy chúng ta ngay lập tức sẽ export Container thành một LoadingWidget.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Loading) {
      return LoadingWidget();
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),

...

class LoadingWidget extends StatelessWidget {
  const LoadingWidget({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

Hiển thị Số Trivia

Chúng ta đang thiếu implement cho trạng thái quan trọng nhất trong tất cả – trạng thái Loaded chứa đối tượng NumberTrivia mà người dùng quan tâm.

Widget TriviaDisplay sẽ rất giống với MessageDisplay, nhưng tất nhiên, nó sẽ nhận đối tượng NumberTrivia thông qua constructor. Ngoài ra, TriviaDisplay sẽ được tạo thành từ một Column hiển thị hai widget Text. Một widget sẽ hiển thị số trong một font lớn, widget khác sẽ hiển thị thông tin thú vị thực tế.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Loading) {
      return LoadingWidget();
    } else if (state is Loaded) {
      return TriviaDisplay(
        numberTrivia: state.trivia,
      );
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),

...

class TriviaDisplay extends StatelessWidget {
  final NumberTrivia numberTrivia;

  const TriviaDisplay({
    Key key,
    this.numberTrivia,
  })  : assert(numberTrivia != null),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Column(
        children: <Widget>[
          // Fixed size, doesn't scroll
          Text(
            numberTrivia.number.toString(),
            style: TextStyle(
              fontSize: 50,
              fontWeight: FontWeight.bold,
            ),
          ),
          // Expanded makes it fill in all the remaining space
          Expanded(
            child: Center(
              // Only the trivia "message" part will be scrollable
              child: SingleChildScrollView(
                child: Text(
                  numberTrivia.text,
                  style: TextStyle(fontSize: 25),
                  textAlign: TextAlign.center,
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

Refactor code một chút

Chúng ta đã làm rất nhiều thứ để giữ code dễ bảo trì bằng cách tạo các widget tùy chỉnh. Tuy nhiên, hiện tại chúng đều nằm trong file ​number_trivia_page.dart​, vì vậy hãy chuyển chúng vào các file riêng của chúng dưới thư mục ​widgets​.

Tệp ​widgets.dart​ là một cái gọi là tệp barrel. Vì Dart không hỗ trợ “package imports” như Kotlin hoặc Java, chúng ta phải tự tìm cách để loại bỏ rất nhiều lệnh import cụ thể bằng cách sử dụng các tệp barrel. Nó đơn giản là xuất (export) tất cả các tệp khác có trong thư mục.

widgets.dart

export 'loading_widget.dart';
export 'message_display.dart';
export 'trivia_display.dart';

Và trong ​number_trivia_page.dart​ chúng ta chỉ cần import file như sau:

import '../widgets/widgets.dart';

Nửa dưới – Nhập dữ liệu

Tất cả các widget mà chúng ta đã tạo đến điểm này sẽ không có ích nếu người dùng không thể khởi tạo việc truy xuất NumberTrivia ngẫu nhiên hoặc cụ thể. Vì chúng ta đang sử dụng Bloc, điều này sẽ xảy ra bằng cách gửi đi các event.

Hiện tại, phần dưới cùng của giao diện người dùng đang chứa nhiều Placeholder, nhưng trước khi thay thế chúng bằng các widget thực tế, hãy tách toàn bộ cột phía dưới thành một custom stateful widget TriviaControls.

number_trivia_page.dart

class TriviaControls extends StatefulWidget {
  const TriviaControls({
    Key key,
  }) : super(key: key);

  @override
  _TriviaControlsState createState() => _TriviaControlsState();
}

class _TriviaControlsState extends State<TriviaControls> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        // Placeholders here...
      ],
    );
  }
}

Tại sao phải là stateful? TriviaControls sẽ có một TextField và để gửi Sting được nhập vào Bloc mỗi khi một nút được nhấn, widget này sẽ cần giữ String đó làm trạng thái local.

Ngoài việc gửi sự kiện GetTriviaForConcreteNumber khi nút cụ thể được nhấn, event này cũng sẽ được gửi khi TextField được gửi đi bằng cách nhấn một nút trên bàn phím.

number_trivia_page.dart

class _TriviaControlsState extends State<TriviaControls> {
  final controller = TextEditingController();
  String inputStr;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextField(
          controller: controller,
          keyboardType: TextInputType.number,
          decoration: InputDecoration(
            border: OutlineInputBorder(),
            hintText: 'Input a number',
          ),
          onChanged: (value) {
            inputStr = value;
          },
          onSubmitted: (_) {
            dispatchConcrete();
          },
        ),
        SizedBox(height: 10),
        Row(
          children: <Widget>[
            Expanded(
              child: RaisedButton(
                child: Text('Search'),
                color: Theme.of(context).accentColor,
                textTheme: ButtonTextTheme.primary,
                onPressed: dispatchConcrete,
              ),
            ),
            SizedBox(width: 10),
            Expanded(
              child: RaisedButton(
                child: Text('Get random trivia'),
                onPressed: dispatchRandom,
              ),
            ),
          ],
        )
      ],
    );
  }

  void dispatchConcrete() {
    // Clearing the TextField to prepare it for the next inputted number
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context)
        .dispatch(GetTriviaForConcreteNumber(inputStr));
  }

  void dispatchRandom() {
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context)
        .dispatch(GetTriviaForRandomNumber());
  }
}

Hãy đặt widget này vào file riêng của nó là ​trivia_controls.dart​ và sau đó thêm nó như một export vào file barrel là ​widgets.dart​.

Hoàn thiện

Người dùng cuối cùng có thể nhập một số và nhận một thông tin thú vị cụ thể hoặc chỉ cần nhấn nút ngẫu nhiên để có một số thông tin thú vị ngẫu nhiên. Tuy nhiên, có một lỗi giao diện người dùng xuất hiện ngay khi chúng ta mở bàn phím:

Điều này có thể được sửa một cách dễ dàng bằng cách bọc toàn bộ nội dung của Scaffold bằng SingleChildScrollView. Với điều này, mỗi khi bàn phím xuất hiện và chiều cao của body giảm đi, nó sẽ trở thành có thể cuộn và không có tình trạng overflow xảy ra.

number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: SingleChildScrollView(
        child: buildBody(context),
      ),
    );
  }
  ...
}

Xong

Trong 14 phần của khóa học Clean Architecture này, chúng ta đã đi từ một ý tưởng đến một ứng dụng hoạt động. Bạn đã học cách thiết kế ứng dụng của mình thành các layer độc lập, khám phá sức mạnh của các contract, học cách thực hiện dependency injection và nhiều điều khác nữa.

Cảm ơn các bạn đã theo dõi.

Viết một bình luận