0. 이글을 쓰기시작한 이유.

예~전에 한참 스위프트 공부에 재미들여서 했을적에 서버드리븐 유아이라는 개념을 듣고 언젠간 공부해봐야지 하고 메모해놨던 일이 있다.

지금이 24년이고, 내가 앱에 입문했을때가 18년이니까 대충 20년쯤이지 아니였을까하는데 뭐 하나 정리를 제대로 안해두는 내 성격상에 여태 까맣게 잊고있다가 최근에 flutter를 공부하다 다시 생각이 났다.

개념자체는 엄청 신기하거나 그러진 않고 앱의 화면에 대한 정보를 서버에서 저장해놓고 앱은 해당 정보를 받아와서 그려는식의 웹뷰를 네이티브 형식으로 구현한 느낌이다.

아 물론 이 설명은 내가 슬쩍 검색해보고 나오는 게시글들의 감상이다

이런식이면 이게 웹앱인지 네이티브앱인지 아리송해지긴하는데 솔직히 요새 서버에서 데이터 받아와서 테이블이나 리스트에 뿌려주는거 보면 어처피 이게 그거 아닌가 싶어서 오히려 왜 방법이 스타트업 사이에서 굴러지고 있는 사람들이 주로 쓰고있지않은건가 의문이든다

요즘은 애플의 앱심사가 정말 빨라졌다지만 그래도 aws에서 파이프라인만들어놔서 빌드하면 자동으로 게시가 되는거 웹페이지에 비교하기에는 너무 천지차이이고 이제막 시작하는 앱이면 이곳저곳 버그가 산재해있거나 바뀌는 아이템으로 앱업데이트를 자주해야하는 상황에선 정말 답답하게된다

이런 상황으로 이전에는 앱시작시 팝업 정보가 있는지 서버에 저장해놨다가 팝업정보가 있으면 앱시작시 띄여주고 해당 팝업 선택시 웹페이지로 연결해주는 방식으로 했었는데 (내 이전직장들이 죄다 망해버려서 오랫동안 쓰이진 않았다) 이런것보단 차라리 server driven ui가 맞지않을까 싶다

UI는 네이티브로 고정해두고 내부 아이템들의 서버변화를 줘서하는거의 크게 다르지 않아서 굳이 기존앱의 변경하는 메리트는 없어보인다

그런데도 이 서버드리븐방식의 글을쓰게된이유는...

블로그에 글을 너무 안올리고있고 거의 반년째 일이 없어 날백수생활을 하고있기때문이다.

뭐라도 해야지....

그렇게 난 flutter로 server driven ui의 구현에 대한 테스트앱을 만들어보기로 한다.

 

 

1. 아이디어 정리

원래는 하루동안 잠깐만 하려고 했던게 조금 쓰고 이제서야 다시 잡았다.

현 시점이 0번항을 적고 나서 시일이 좀 지난 시점이라 당시 어떤 생각을 했는지 좀 잊어버렸다

살짝 짜둔 코드를 보며 다시 아이디어를 정리하고 가야겠다.

내 앱은 우선

전체 구성은 변경하지 않도록 했다 이것도 잘 생각해보면 앱 구성자체를 코드형태로 서버에 저장했다가

스플래시화면에서 받아서 렌더링하는 방법으로 해도 될듯하긴한데 이렇게 하면 차라리 웹앱으로 하는게 맞지않나 싶어서 이것은 포기했다

그렇다면 어느정도 틀만 만들어두고 해당 틀내용만 받아오는식으로 할 생각인데

 

대강 각 탭 페이지는 라우트로 만들어두고 

각 페이지는 카드로 구성되어 있고 각 카드는 몇 종류로 나눠있으며 해당 카드를 미리 만들어두어( 스위프트 uikit의 테이블셀 처럼 ) 

페이지 첫 로드시 각 카드의 순서, 종류, 종류에 따른 내부 컨텐츠 값을 받아와서 화면에 그려주는 방식으로 하려고 한다

 

생각이 깊어질수록 플러터 기본 제공 위젯을 이용하면되는게 아닌가 싶어지기도하는데 이래서 따로 안만들어두고 차라리 웹앱으로 가던가 하는건가....

특정 디자인이 이미 구현되어 있고 꽤나 다른 앱과 다른 유니크한 디자인이라면 일종의 UIkit을 만들어서 제공하는게 있을지도 모르겠다 

대기업 앱엔 있을지도?

 

뭐 아무튼 이번 프로젝트는 방법이 구현이 되는가가 중요하기에 범위자체는 크게 하지 않을거다

많은 앱에서 사용하는 배너용 카드, 단순 글 알림카드?, 사진들어간 좀 큰카드 이렇게 세가지 정도만 화면에서 보여지고

이걸 보여주기위한 넓~직한 카드 읽기용 글카드(사진 포함) -> 단순 글카드와 사진들어간 큰카드 공용으로 쓰고

보통 구현한다는 crud에서 r만 해서 간단히 보여주기 자세히보여주기 만하자

그렇지만 너무 내용이 없어보이면 추가로 이것저것 추가해야겠다

 

그래서 생각나는데로 적은 글들을 정리하자면

1. 메인 페이지 겉부분은 코드로 작성

2. 페이지에 들어가는 내용은 '카드'단위로 나누어서 각 카드별 디자인코드 작성

3. 페이지 로드시 페이지에 들어갈 카드내용을 불러와서 랜더링

4. 카드 내용은 종류, 본문 + 연결될 링크(앱 페이지~ 또는 웹링크~)

 

간단하게 할거라 디자인도 기본으로 제공해주는 위젯을 살짝 래핑하는 식으로 할거다.

일단 다음에 해야지....

 

// ---------

25.01.16일 재시작

프로젝트 시작만 해두고 냅둔애들이 너무 쌓이기시작해서 일단 빠르게 끝낼수 있는 이것먼저 끝내기로함.

위 구성은 잠깐 한두시간한다고 끝낼수 있을거같지않아서 내용을 줄이려한다.

페이지는 2개, 들어가는 스플래시 페이지와, 메인페이지

스플래시는 원래 전체 앱구성을 받고 각 페이지를 웹페이지마냥 코드 읽어서 그려줄려고했는데

스플래시는 하는일 없이 그냥 넘기기로 함

카드구성도 좀 다양하게 만들고 나중에도 써먹을수 있게 템플릿형태로 만들려고 한거같은데 수정

그냥 적당히 4가지 형태로 하고 코드 구현도 chat gpt 무료버전을 이용해 채우도록 하려함

지금이 새벽 3시반 정도되는데 아침되기전에 마무리하고 포스트 업로드하는거로 목표

 

2. 구현

일단 아이디어 답게

페이지가 열리면 api로 화면에 대한 데이터값을 요청 -> 값을 받으면 코드를 그림 

이러는 형식으로 했다

Future<List<CardDatum>> fetchPageData() 를 구현하고

빌드에서 해당 함수를 호출 

Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async => false,
      child: Scaffold(
        body: SafeArea(
            child: FutureBuilder<List<CardDatum>>(
          future: fetchPageData(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              if (snapshot.data!.isEmpty) {
                return const CircularProgressIndicator();
              }
              var cards = buildCards(snapshot.data!);
              return ListView(
                children: [...cards],
              );
            } else if (snapshot.hasError) {
              return Text('${snapshot.error}');
            }
            return const CircularProgressIndicator();
          },
        )),
      ),
    );
  }

값에 대한 오류가 났을때 재로드하는 식으로 해줄수도 있지만 졸리고 빨리 끝내고 싶어서 로딩바가 계속 굴러가게 했다.

buildCards() 함수는

  List<Widget> buildCards(List<CardDatum> cardData) {
    List<Widget> cardList = [];

    // 카드 밑에 높이8의 빈공간 추가
    for (var datum in cardData) {
      CardType? type = datum.cardType();
      if (type == null) {
        ErrorOBj eobj = ErrorOBj(
            type: "build error", message: "카드 데이터의 타입이 올바르지 않다(${datum.type})");
        print(eobj.toString());
        continue;
      }

      Widget cardWidget = BuildCardUtil().build(type, datum.data);
      cardList.add(cardWidget);
      Widget blank = const SizedBox(height: 8);
      cardList.add(blank);
    }

    return cardList;
  }

따로 타입별 위젯 만드는 클래스를 분리하고 오류로그 찍는 정도 + 각 카드별 공백 잡아줬다

 

카드 빌드하는 함수는

class BuildCardUtil {
  Widget build(CardType type, dynamic data) {
    Map<String, dynamic> map = data as Map<String, dynamic>;

    switch (type) {
      case CardType.banner:
        String imageUrl = map["imageUrl"] as String;
        String link = map["link"] as String;
        return ImageBannerCard(imageUrl: imageUrl, link: link);

      case CardType.profile:
        String name = map["name"] as String;
        String bio = map["bio"] as String;
        String imageUrl = map["imageUrl"] as String;
        return ProfileCard(name: name, bio: bio, imageUrl: imageUrl);

      case CardType.squareImage:
        String imageUrl = map["imageUrl"] as String;
        return SquareImageCard(imageUrl: imageUrl);

      case CardType.titleAndSub:
        String title = map["title"] as String;
        String subtitle = map["subtitle"] as String;
        return TitleSubtitleCard(title: title, subtitle: subtitle);

      default:
        return Container();
    }
  }
}

이런식으로 각 카드클래스를 따로 만들어 연결해주는 방법으로 한단계를 분리하여 확장하기 편하게 했으나...

딱히 앞으로 확장할일이 없기에 괜한일을 했나싶다 한두시간내에 끝내려고했는데 이미 6시가 가까워지고있어서 그냥 손가락 가는데로 타이핑했다

 

추가로 각 카드 클래스들은 gpt를 이용해 뽑아서 간단하게 수정만해줌...

무료버전이라 그런가 애가 실수가 많네...

 

api는 어떻게 해줄까 한참을 고민하고 로컬로 서버 만들어서 할까 아니면 파베에서 공짜로 주는 함수기능을 이용할까 

검색하다가 포스트맨을 이용해 mockApi를 만들어 제공해준다길래 이미 깔려있기도해서 이거 이용해봤다

매우 만족!!!

이런 좋은 기능을 이제야 알았다니..

아래 사진처럼 요청주소, 반환값 이런거 저장해두고

 

아래 mock servers에서 생성해주면!!!!

요청 로그까지 찍어준다!

 

그렇게 아래와같은 예제가 완성되었다

 

실 컨텐츠값은 서버에서 제공하고 앱은 카드형식 만드는거만 지정해서 카드추가와 삭제는 서버에서 지정이 가능한

나름 서버드리븐 앱

원래 구상은 좀더 앱같이 만드려고했으나 어쩌다보니 이렇게 간단한 예제가 되어버리며 프로젝트를 마무리한다.

코드는 내 깃허브에 올릴것이다

 

 

+ 추가 깃링크

https://github.com/wiwi-git/server_driven_test

 

GitHub - wiwi-git/server_driven_test: server driven ui 방식의 테스트

server driven ui 방식의 테스트. Contribute to wiwi-git/server_driven_test development by creating an account on GitHub.

github.com

 

반응형

hive로 어떻게든 해보려다가 조건을 붙여서 하는게 영 너무 힘들어서 결국 sqlite로 넘어가려고 flutter용 sqlite인 sqflite를 설치해서 사용하려고 하던중 

안드로이드는 잘 돌아가나 ios에서 제목과같은 오류가 나면서 안된다.

In Podfile:
        sqflite (from `.symlinks/plugins/sqflite/darwin`)

    Specs satisfying the `sqflite (from `.symlinks/plugins/sqflite/darwin`)` dependency were found, but they required a higher minimum deployment target.

평소처럼 구글님의 은혜를 받아 우선 번역기를 돌려보면 

`sqflite(`.symlinks/plugins/sqflite/darwin`에서)` 종속성을 충족하는 사양이 발견되었지만 더 높은 최소 배포 대상이 필요했습니다.

라고하니 단순히 최소 배포값이 더 높은건가 싶어 sqflite 페이지를 살펴봐도 최소 버전에대한 안내가없다.

결국 그냥 평소에 많이 잡는 12로 하자싶어 12로 변경하고 pods폴더 날리고 podfile.lock파일도 날리고 거기에 굳이 필요하나 싶지만 flutter clean도 요청해주고 다시 설치하니 돌아간다.

 

참고로 cocoapod이 설치되는 지원 버전은 podfile에 정의되어있다.

버전별로 다를수있으나 내가 쓰는 flutter에서의 자동으로 만들어진 Podfile에서는

# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'

이렇게 시작한다.

이 문서의 두번째 주석을 풀어주고 값을 12로 변경해줬다.

# Uncomment this line to define a global platform for your project
platform :ios, '12.0'

 

반응형

내가 상속을 배웠던 언어는 get과 set을 분리해서 정의하는게 없었던 언어라 ( 내가 배우지 않은것일수도 있다 ) set만 오버라이드 할 경우 super를 호출되도 잘되는가에 대한 테스트가 필요했다.

테스트용이라 짧디 짧은 코드지만 그냥 날려버리기 뭔가 아깝다 생각이 들어 글을 남기기로 결정.

class A {
  A() {
    print('A가 생성이 시작되는 시점 - aValue: ${aValue}');
  }
  String aValue = 'default value';
}

class B extends A {
  B() {
    print('B가 생성이 시작되는 시점 - aValue: ${aValue}');
    super.aValue = 'initValue';
  }

  @override
  set aValue(String value) {
    print('set:$value B에서의 오버라이드한 함수가 불려졌다.');
    super.aValue = value;
  }
}

void main() {
  final b = B(); // 출력:A가 생성이 시작되는 시점 - aValue: default value,  B가 생성이 시작되는 시점 - aValue: default value
  print('시점 0 ~ b의 값 : ${b.aValue}'); // 출력: 시점 0 ~ b의 값 : initValue

  b.aValue = 'main - value'; // 출력: set:main - value B에서의 오버라이드한 함수가 불려졌다.

  print('시점 1 ~ b의 값 : ${b.aValue}'); // 출력: 시점 1 ~ b의 값 : main - value
}

set에서만 override를 해서 aValue를 설정할때 따로 print가 되도록 설정이 잘됐다.

super 지정이 잘 동작 한다.

뭐라 쓸 내용이 없네

반응형

플러터앱 외주 작업중 국가 코드로 이름을 가져와야되는 귀찮은 상황이 생겨서 위키에서 긁은걸 gpt로 넘겨서 작성해봄.

맵쪽은 나중에 다른 언어에서도 사용할수있을듯하다.

chat gpt가 신생언어에서 이상한 소리를 많이 하는데 이런 텍스트 포맷 변경은 참 잘한다.

class CountryISOAlpha2 {
  String? getCode(String name) {
    for (var key in _countrys.keys) {
      if (_countrys[key] == name) {
        return key;
      }
    }

    return null;
  }

  String? getName(String code) {
    return _countrys[code];
  }

  final Map<String, String> _countrys = {
    'AD': '안도라',
    'AE': '아랍에미리트',
    'AF': '아프가니스탄',
    'AG': '앤티가 바부다',
    'AI': '앵귈라',
    'AL': '알바니아',
    'AM': '아르메니아',
    'AO': '앙골라',
    'AQ': '남극',
    'AR': '아르헨티나',
    'AS': '아메리칸사모아',
    'AT': '오스트리아',
    'AU': '오스트레일리아',
    'AW': '아루바',
    'AX': '올란드 제도',
    'AZ': '아제르바이잔',
    'BA': '보스니아 헤르체고비나',
    'BB': '바베이도스',
    'BD': '방글라데시',
    'BE': '벨기에',
    'BF': '부르키나파소',
    'BG': '불가리아',
    'BH': '바레인',
    'BI': '부룬디',
    'BJ': '베냉',
    'BL': '생바르텔레미',
    'BM': '버뮤다',
    'BN': '브루나이',
    'BO': '볼리비아',
    'BQ': '보네르섬',
    'BR': '브라질',
    'BS': '바하마',
    'BT': '부탄',
    'BV': '부베섬',
    'BW': '보츠와나',
    'BY': '벨라루스',
    'BZ': '벨리즈',
    'CA': '캐나다',
    'CC': '코코스 제도',
    'CD': '콩고 민주 공화국',
    'CF': '중앙아프리카 공화국',
    'CG': '콩고 공화국',
    'CH': '스위스',
    'CI': '코트디부아르',
    'CK': '쿡 제도',
    'CL': '칠레',
    'CM': '카메룬',
    'CN': '중국',
    'CO': '콜롬비아',
    'CR': '코스타리카',
    'CU': '쿠바',
    'CV': '카보베르데',
    'CW': '퀴라소',
    'CX': '크리스마스섬',
    'CY': '키프로스',
    'CZ': '체코',
    'DE': '독일',
    'DJ': '지부티',
    'DK': '덴마크',
    'DM': '도미니카 연방',
    'DO': '도미니카 공화국',
    'DZ': '알제리',
    'EC': '에콰도르',
    'EE': '에스토니아',
    'EG': '이집트',
    'EH': '서사하라',
    'ER': '에리트레아',
    'ES': '스페인',
    'ET': '에티오피아',
    'FI': '핀란드',
    'FJ': '피지',
    'FK': '포클랜드 제도',
    'FM': '미크로네시아 연방',
    'FO': '페로 제도',
    'FR': '프랑스',
    'GA': '가봉',
    'GB': '영국',
    'GD': '그레나다',
    'GE': '조지아',
    'GF': '프랑스령 기아나',
    'GG': '건지섬',
    'GH': '가나',
    'GI': '지브롤터',
    'GL': '그린란드',
    'GM': '감비아',
    'GN': '기니',
    'GP': '과들루프',
    'GQ': '적도 기니',
    'GR': '그리스',
    'GS': '사우스조지아 사우스샌드위치 제도',
    'GT': '과테말라',
    'GU': '괌',
    'GW': '기니비사우',
    'GY': '가이아나',
    'HK': '홍콩',
    'HM': '허드 맥도널드 제도',
    'HN': '온두라스',
    'HR': '크로아티아',
    'HT': '아이티',
    'HU': '헝가리',
    'ID': '인도네시아',
    'IE': '아일랜드',
    'IL': '이스라엘',
    'IM': '맨섬',
    'IN': '인도',
    'IO': '영국령 인도양 지역',
    'IQ': '이라크',
    'IR': '이란',
    'IS': '아이슬란드',
    'IT': '이탈리아',
    'JE': '저지섬',
    'JM': '자메이카',
    'JO': '요르단',
    'JP': '일본',
    'KE': '케냐',
    'KG': '키르기스스탄',
    'KH': '캄보디아',
    'KI': '키리바시',
    'KM': '코모로',
    'KN': '세인트키츠 네비스',
    'KP': '조선민주주의인민공화국',
    'KR': '대한민국',
    'KW': '쿠웨이트',
    'KY': '케이맨 제도',
    'KZ': '카자흐스탄',
    'LA': '라오스',
    'LB': '레바논',
    'LC': '세인트루시아',
    'LI': '리히텐슈타인',
    'LK': '스리랑카',
    'LR': '라이베리아',
    'LS': '레소토',
    'LT': '리투아니아',
    'LU': '룩셈부르크',
    'LV': '라트비아',
    'LY': '리비아',
    'MA': '모로코',
    'MC': '모나코',
    'MD': '몰도바',
    'ME': '몬테네그로',
    'MF': '생마르탱',
    'MG': '마다가스카르',
    'MH': '마셜 제도',
    'MK': '북마케도니아',
    'ML': '말리',
    'MM': '미얀마',
    'MN': '몽골',
    'MO': '마카오',
    'MP': '북마리아나 제도',
    'MQ': '마르티니크',
    'MR': '모리타니',
    'MS': '몬트세랫',
    'MT': '몰타',
    'MU': '모리셔스',
    'MV': '몰디브',
    'MW': '말라위',
    'MX': '멕시코',
    'MY': '말레이시아',
    'MZ': '모잠비크',
    'NA': '나미비아',
    'NC': '누벨칼레도니',
    'NE': '니제르',
    'NF': '노퍽섬',
    'NG': '나이지리아',
    'NI': '니카라과',
    'NL': '네덜란드',
    'NO': '노르웨이',
    'NP': '네팔',
    'NR': '나우루',
    'NU': '니우에',
    'NZ': '뉴질랜드',
    'OM': '오만',
    'PA': '파나마',
    'PE': '페루',
    'PF': '프랑스령 폴리네시아',
    'PG': '파푸아뉴기니',
    'PH': '필리핀',
    'PK': '파키스탄',
    'PL': '폴란드',
    'PM': '생피에르 미클롱',
    'PN': '핏케언 제도',
    'PR': '푸에르토리코',
    'PS': '팔레스타인',
    'PT': '포르투갈',
    'PW': '팔라우',
    'PY': '파라과이',
    'QA': '카타르',
    'RE': '레위니옹',
    'RO': '루마니아',
    'RS': '세르비아',
    'RU': '러시아',
    'RW': '르완다',
    'SA': '사우디아라비아',
    'SB': '솔로몬 제도',
    'SC': '세이셸',
    'SD': '수단',
    'SE': '스웨덴',
    'SG': '싱가포르',
    'SH': '세인트헬레나',
    'SI': '슬로베니아',
    'SJ': '스발바르 얀마옌 제도',
    'SK': '슬로바키아',
    'SL': '시에라리온',
    'SM': '산마리노',
    'SN': '세네갈',
    'SO': '소말리아',
    'SR': '수리남',
    'SS': '남수단',
    'ST': '상투메 프린시페',
    'SV': '엘살바도르',
    'SX': '신트마르턴',
    'SY': '시리아',
    'SZ': '에스와티니',
    'TC': '터크스 케이커스 제도',
    'TD': '차드',
    'TF': '프랑스령 남방 및 남극 지역',
    'TG': '토고',
    'TH': '태국',
    'TJ': '타지키스탄',
    'TK': '토켈라우',
    'TL': '동티모르',
    'TM': '투르크메니스탄',
    'TN': '튀니지',
    'TO': '통가',
    'TR': '튀르키예',
    'TT': '트리니다드 토바고',
    'TV': '투발루',
    'TW': '중화민국',
    'TZ': '탄자니아',
    'UA': '우크라이나',
    'UG': '우간다',
    'UM': '미국령 군소 제도',
    'US': '미국',
    'UY': '우루과이',
    'UZ': '우즈베키스탄',
    'VA': '바티칸 시국',
    'VC': '세인트빈센트 그레나딘',
    'VE': '베네수엘라',
    'VG': '영국령 버진아일랜드',
    'VI': '미국령 버진아일랜드',
    'VN': '베트남',
    'VU': '바누아투',
    'WF': '왈리스 푸투나',
    'WS': '사모아',
    'YE': '예멘',
    'YT': '마요트',
    'ZA': '남아프리카 공화국',
    'ZM': '잠비아',
    'ZW': '짐바브웨',
  };
}
반응형

+ Recent posts