[Flutter][GetX] 만보기 앱 개발일지 - 3 | GetConnect로 http 호출하고 받아온 데이터 UI에 구현해보기

dc143c·2023년 1월 27일
1
post-thumbnail
post-custom-banner

0.

이번 게시글에서는 getX 패턴을 사용하여 http를 호출하고, api 서버에서 받아온 데이터를 데이터 모델과 UI 모델에 전달하여 상품 페이지를 구현해보는 과정을 기술해본다.

플러터에서 기본적으로 제공하는 http 호출 방식이 있으나, 그건 블로그 첫번째 게시물에 이미 소개한 바가 있다.

더불어 나는 프로젝트 앱을 모두 getX 패턴으로 작성할 계획이라, getX 라이브러리에서 제공해주는 GetConnnect 패키지를 사용하여 http를 호출해보려 한다.

해당 게시글의 내용은 기능 구현을 위한 정석적인 방법이 아닐수 있으며, 구현 과정이 미숙하거나 정리되지 않은 코드가 포함될수도 있다.

그럼에도 플러터를 학습하는 누군가와, 얼마 못가 이 내용을 까먹을 미래의 나를 위해 이 게시글을 작성해본다.

1. 사전 준비

우선 api 서버 백엔드가 마련되어 있어야 한다.

이 내용은 이미 첫 게시글 에 기술했으나 짧게 요약하자면, 필자는 로컬 환경에 Node.js express 웹 서버mySQL DB 서버를 연결하여 데이터 CRUD 기능을 맡아줄 간단한 api 서버를 올려놓은 상태다.

플러터를 학습하기 전에 이미 백엔드 지식을 경험해본 분들이라면 해당 서버의 구현 방식은 필자보다도 훨씬 더 잘 알고계실 터다.

그럼에도 약간의 설명을 덧붙이자면,

//node.js express server
//server.js
app.get('/getStbCoffee', function(req, res){
  var sql = 'select * from product_stb';
  con.query(sql, function(err, id, fields){
    var product_id = req.params.id;
    if(id){
      var sql='select * from product_stb'
      con.query(sql, function(err, id, fields){
        if(err){
          console.log(err);
        }else{
          res.json(id);
        }
      })
    }
  })
})

데이터 테이블에서 데이터를 받아오는 기능을 하는 메소드는 이러하고,

우리가 사용할 데이터 테이블은 대강 이런 구조다.

프라이머리 키로 product_id를 가지고, 이름, 브랜드, 가격, 이미지 패스를 갖고 있다.

다른 브랜드의 상품 데이터 테이블도 동일한 구조를 가지고 있으며, 이는 flutter에서 같은 구조의 테이블을 하나의 데이터 모델로 묶어서 처리하기 위함이다. 이는 후술.

조금 더 여유가 있거나 정식 서비스를 할 예정이라면 클라우드 서비스를 이용해 서버를 호스팅하는것도 좋을 듯하다.

2. Provider

프로바이더의 사전적 정의는 공급자다.

동명의 상태관리 라이브러리와 혼동을 유의하라. 어떻게 보면 하는 역할은 비슷하긴 하다.

//사이클.
//api 를 호출하는 프로바이더를 선언 ->
//각 UI 의 컨트롤러에서 프로바이더의 원하는 프로바이더를 호출 ->
//프로바이더는 productFromJson 으로 데이터 모델에 데이터를 넘겨
//flutter UI 에서 활용할수 있도록 바꿔줌 ->
//최종 유저가 사용할 UI 에서 빌더를 사용해 UI 모델에 데이터 값을 하나씩 담아줌. ->
//유저가 시인할 UI에 빌드.
class ProductProvider extends GetConnect implements GetxService {
  //api get 호출 메소드.
  Future<List<CoffeeProductModel>?> getStbProductData() async {
    final response = await get('http://localhost:8000/getStbCoffee');
    print(response.body);
    if (response.status.hasError) {
      return Future.error({response.statusText});
    } else {
      return productFromJson(response.body);
    }
  }

  Future<List<CoffeeProductModel>?> getTwsProductData() async {
    final response = await get('http://localhost:8000/getTwsCoffee');
    print(response.body);
    if (response.status.hasError) {
      return Future.error({response.statusText});
    } else {
      return productFromJson(response.body);
    }
  }

  Future<List<CoffeeProductModel>?> getYdyProductData() async {
    final response = await get('http://localhost:8000/getYdyCoffee');
    print(response.body);
    if (response.status.hasError) {
      return Future.error({response.statusText});
    } else {
      return productFromJson(response.body);
    }
  }

  
  void onInit() {
    super.onInit();
  }
}

ProductProvider는 문자 그대로 상품 데이터를 공급하는 역할이다.

getX 에서 제공하는 http 통신 라이브러리인 GetConnet를 상속하여 getX 패턴의 http 통신 기능을 쓸수 있도록 한다.

필자는 response 변수에 데이터를 받아오게끔 했다.

if문을 거쳐 데이터를 받아오는데 성공했다면, productFromJson에 응답 값(response.body)를 넘기도록 한다.

3. productFromJson(데이터 모델)

List<CoffeeProductModel> productFromJson(dynamic str) =>
    List<CoffeeProductModel>.from(
        (str).map((x) => CoffeeProductModel.fromJson(x)));

//데이터 모델.
//데이터베이스의 구조에 맞추어 변수에 데이터베이스에서 가져온 데이터를 담아줌.
//동일한 구조를 사용하는 테이블은 하나로 묶어 하나의 데이터 모델을 거쳐감.
class CoffeeProductModel {
  int? productId;
  String? productName;
  String? productBrand;
  String? productPrice;
  String? productImagePath;

  CoffeeProductModel(this.productId, this.productName, this.productBrand,
      this.productPrice, this.productImagePath);

  CoffeeProductModel.fromJson(Map<String, dynamic> json) {
    productId = json['product_id'];
    productName = json['name'];
    productBrand = json['brand'];
    productPrice = json['price'];
    productImagePath = json['image_path'];
  }

  Map<String, dynamic> toJson() => {
        "product_id": productId.toString(),
        "name": productName,
        "brand": productBrand,
        "price": productPrice,
        "image_path": productImagePath
      };
}

productFromJson 은 모델링을 거쳐 flutter의 UI에 쓸수 있도록 변수의 담긴 데이터를 다시 받아오는 리스트 인자값을 가진 메소드다.

CoffeeProductModel 으로 명명한 데이터 모델이 호출받은 api 의 json 값을 각각 받아와 모델에 선언된 변수에 담아주도록 한다.

업데이트나 인서트를 위해 데이터를 다시 넘길 경우에는 toJson 을 통해 그 반대의 기능을 한다.

위에서 테이블을 같은 구조로 작성한 이유가 바로 여기에 있다. 세 브랜드 상품 테이블 모두 동일한 json 구조를 가지고 있으니, 하나의 모델로 세 개의 테이블의 데이터를 변수에 담아줄수 있다.

일상의 언어로 비유하자면, 우리는 네모난 모양의 그릇 하나(Product model) 만 가지고 있는 상태이고, 세 가지 다른 맛의 식빵을(세 개의 브랜드 상품 데이터 테이블) 그릇의 모양으로 잘 잘라내어 하나의 그릇으로도 모두 담을수 있도록 하는 것이다.
각자 맞는 모양의 그릇을 세 개 다 따로 준비할 수도 있겠다만, 어차피 식빵은 네모낳고(데이터 테이블 구조가 다 같음), 반복되는 일은 귀찮지 않는가?

4. Flutter UI에 출력하기

데이터를 호출했고, 그걸 데이터 모델로 받아주었다. 그럼 이제 이걸 UI에 활용해보자.

Contorller

class CoffeeController extends GetxController
    with StateMixin<List<CoffeeProductModel>> {
  //페이지 인덱스
  final RxInt selectedIndex = 0.obs;

  //프로바이더 선언. 구조를 공유하는 테이블들은 같은 프로바이더 사용.
  ProductProvider productProvider = ProductProvider();

  void changeIndex(int index) {
    selectedIndex(index);
  }

  //브랜드 버튼을 순서대로 나열, 브랜드 버튼을 누르면 선택 인덱스의 값이 바뀌며 페이지 전환.
  //동시에 프로바이더의 json 호출 메소드를 호출하여 데이터를 호출함.
  isBrand0IndexClicked() {
    selectedIndex.value = 0;
    print(selectedIndex.value);
    productProvider.getStbProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

  isBrand1IndexClicked() {
    selectedIndex.value = 1;
    print(selectedIndex.value);
    productProvider.getTwsProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

  isBrand2IndexClicked() {
    selectedIndex.value = 2;
    print(selectedIndex.value);
    productProvider.getYdyProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

  isBrand3IndexClicked() {
    selectedIndex.value = 3;
    print(selectedIndex.value);
    productProvider.getStbProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

  
  void onInit() {
    super.onInit();
    productProvider.getStbProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

  
  void onReady() {
    super.onReady();
  }

  
  void onClose() {
    super.onClose();
  }
}

View

class CoffeeView extends GetView<CoffeeController> {
  const CoffeeView({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    Get.put(CoffeeController());

    final List<Widget> coffeePages = [
      CoffeeBrandStbView(),
      CoffeeBrandTwsView(),
      CoffeeBrandYdyView(),
    ];

    return Scaffold(
      backgroundColor: bgColor,
      appBar: AppBar(
        iconTheme: IconThemeData(color: textDark),
        title: Text(
          'Coffee',
          style: TextStyle(
              fontFamily: 'LS',
              fontSize: 30,
              fontWeight: FontWeight.w700,
              color: accentYellow),
        ),
        centerTitle: false,
        elevation: 0,
        backgroundColor: bgColor,
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Expanded(
            flex: 1,
            child: ProfileNCash(),
          ),
          SizedBox(
            height: 2,
            width: double.infinity,
            child: Container(
              color: Colors.grey.shade300,
            ),
          ),
          Expanded(
            flex: 2,
            child: Padding(
              padding: const EdgeInsets.all(18.0),
              child: ListView(
                scrollDirection: Axis.horizontal,
                children: [
                  BrandTile(
                    brandChild: CircleAvatar(
                      backgroundColor: Colors.green,
                      radius: 20,
                    ),
                    brandName: 'STB',
                    onTap: () => controller.isBrand0IndexClicked(),
                  ),
                  BrandTile(
                    brandChild: CircleAvatar(
                      backgroundColor: Colors.red,
                      radius: 20,
                    ),
                    brandName: 'TWS',
                    onTap: () => controller.isBrand1IndexClicked(),
                  ),
                  BrandTile(
                    brandChild: CircleAvatar(
                      backgroundColor: Colors.purple,
                      radius: 20,
                    ),
                    brandName: 'YDY',
                    onTap: () => controller.isBrand2IndexClicked(),
                  ),
                ],
              ),
            ),
          ),
          Expanded(
            flex: 11,
            child: Obx(
              () => SafeArea(
                child: coffeePages[controller.selectedIndex.value],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

UI

첫 게시글의 사전 준비 목차 에서 보여주었던 getX 패턴을 사용한 바텀 네비게이션 바와 비슷하게 페이지를 구성했다.

뷰에서는 브랜드를 선택하는 UI와 그걸 인덱스로 받아주는 컨트롤러로만 구성하고, 화면 중심에 표시되는 페이지는 따로따로 위젯을 만들어 페이지 리스트에 넣는 방식.

컨트롤러 현재 페이지를 감지할 인덱스 값과, 뷰의 구성이 List로 정의된 세 개의 브랜드 상품 페이지와 그걸 받아주는 SafeArea로 구성된 이유도 이것 때문.

여하튼 중요한 건, 컨트롤러 부분의 isBrandNIndexClicked이다.

//동일한 소스 잘라서 보기.
isBrand0IndexClicked() {
    selectedIndex.value = 0;
    print(selectedIndex.value);
    productProvider.getStbProductData().then((response) {
      change(response, status: RxStatus.success());
      print(response);
    }, onError: (e) {
      change(null, status: RxStatus.error(e.toString()));
    });
  }

UI 상단의 브랜드 버튼을 클릭하게 되면, 컨트롤러는 각 순번에 맞는 isBrandNIndexClicked를 호출하여 페이지 인덱스를 바꾸고, 동시에 해당 브랜드 상품 데이터를 받아오게 된다.

//ProductProvider의 일부.
Future<List<CoffeeProductModel>?> getStbProductData() async {
    final response = await get('http://localhost:8000/getStbCoffee');
    print(response.body);
    if (response.status.hasError) {
      return Future.error({response.statusText});
    } else {
      return productFromJson(response.body);
    }
  }

1번 목차의 프로바이더에서 떼어놓은 getStbProductData.

Stb 브랜드의 상품 데이터를 호출하고, 이 데이터를 productFromJson으로 넘겨준다.

브랜드 상품 페이지 UI

class CoffeeBrandStbView extends GetView<CoffeeController> {
  const CoffeeBrandStbView({Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: bgColor,
      body: SafeArea(
        child: Column(
          children: [
            SizedBox(
              width: double.infinity,
              height: 20,
              child: Container(
                color: Colors.grey.shade300,
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
                  child: Text(
                    'STB',
                    style:
                        TextStyle(color: textDark, fontWeight: FontWeight.w300),
                  ),
                ),
              ),
            ),
            Expanded(
              child: controller.obx(
                (data) => ListView.builder(
                  itemCount: data?.length,
                  itemBuilder: (context, index) {
                    //UI 모델에 데이터 세부값을 인덱스로 넘기기
                    var details = data?[index];
                    //디테일 값은 받은 User UI 모델이 아이템 빌더로 빌드됨.
                    return StbUIModel(
                      model: details,
                    );
                  },
                ),
                // onError: (err) => Text('e'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

그 뒤에는 해당 페이지(이 경우에는 Stb 브랜드의 페이지)UI의 빌더 위젯을 controller.obx로 감싸 데이터를 넘겨준다.

빌더 위젯 안에 details 변수를 선언하여 인덱스에 맞는 데이터를 만들어주고, 최종적으로 이를 UI 모델에 넘겨준다.

그러면 리턴받은 StbUIModel의 모양대로 갯수에 맞게 리스트뷰 빌더로 객체가 빌드된다.

UI 모델

class StbUIModel extends StatelessWidget {
  const StbUIModel({Key? key, this.model}) : super(key: key);

  final CoffeeProductModel? model;

  
  Widget build(BuildContext context) {
    String baseUrl = '';

    return Container(
      height: 130,
      width: double.infinity,
      color: bgColor,
      child: Padding(
        padding: EdgeInsets.all(10),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(
              width: 10,
            ),
            Expanded(
              flex: 3,
              child: SizedBox(
                width: 60,
                height: 90,
                child: Container(
                  child: Image.network(
                    'http://localhost:8000/${model!.productImagePath!}',
                    fit: BoxFit.fill,
                  ),
                ),
              ),
            ),
            SizedBox(
              width: 13,
            ),
            Expanded(
              flex: 7,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  SizedBox(
                    height: 10,
                  ),
                  Text(
                    model!.productBrand!,
                    style: TextStyle(color: Colors.grey.shade600),
                  ),
                  Text(model!.productName!),
                  SizedBox(
                    height: 3,
                  ),
                  Row(
                    children: [
                      CircleAvatar(
                        radius: 9,
                        backgroundColor: accentYellow,
                        child: Text(
                          'C',
                          style: TextStyle(
                              fontFamily: 'LS',
                              fontWeight: FontWeight.w700,
                              color: bgColor),
                        ),
                      ),
                      SizedBox(
                        width: 5,
                      ),
                      Text(model!.productPrice!),
                    ],
                  )
                ],
              ),
            ),
            SizedBox(
              width: 20,
            ),
          ],
        ),
      ),
    );
  }
}

리스트뷰에 빌드되는 UI.

이건 정답이 없다. 원하는 디자인으로 데이터가 들어가야 할 곳에 model 변수를 사용하여 데이터를 삽입하자.

5. 마무리

각 브랜드 페이지별로 테이블 데이터가 잘 출력되는 모습.

이번 게시글의 내용은 꽤나 복잡했기 때문에, 무엇보다 내가 외우기 힘들기 때문에... 추가로 기능 구현 사이클을 정리해보려 한다.

이 게시글 자체가 우선 내가 메모하고 이해하기 위한 용도인지라, 정리 역시도 나만의 언어로 기술되어서 정석적인 해석과는 거리가 멀다. 유의 바람.

-기능 이름으로 사이클 정리-

해당 기능 구현은 총 다섯가지 파트로 구성되어있다.

ProductProvider : api 호출 기능을 지님.

ProductModel : 데이터 모델. json 데이터를 flutter에서 사용할수 있도록 변수로 담아줌. 즉, 백엔드의 데이터를 받아주는 프론트엔드의 그릇.

productFromJson : 그 그릇에 연결하기 위해 ProductModel을 인자로 받는 리스트 함수. 내 소스 코드에서는 같은 파일에 존재함. 연결고리.

UIModel : View에 빌드되기 위해 존재하는 재료. UI 디자인을 갖고있고, ProductModel의 데이터를 model 변수로 받아와 각 데이터가 필요한 UI에 삽입.

컨트롤러와 뷰로 구성된 UI : 컨트롤러는 ProductProvider를 호출하여 데이터를 받아오고, 뷰는 컨트롤러의.obx로 받아온 데이터를 빌더 위젯에 넘겨서 UI를 구성함.

-유저 입장에서의 사이클-

유저가 페이지 진입 ->

유저가 UI의 페이지를 바꾸면 컨트롤러가 이를 감지, 해당 페이지가 받아야 할 productProvider를 호출 ->

해당 브랜드의 productProvider가 api를 호출. json 데이터로 받아옴 ->

응답받은 json 데이터의 body를 productFromJson을 호출하는 방식으로 데이터 모델에 넘김->

이 데이터 모델을 변수의 형태로 UI 모델이 받아옴 ->

뷰 UI에서 빌더를 통해 UI모델을 객체로 하여 빌드. 이를 유저가 시인. 완성.

profile
크로스플랫폼 앱 개발자
post-custom-banner

0개의 댓글