HTML + CSS 스터디 1주차 스터디 정리내용

부산에서 매주 진행되는 CSS 초중급 스터디입니다.

목차

  • Flex

    • Flex Basic

    • Main Axis and Cross Axis

    • Flex Wrap and Direction

      • wrap

      • flex-direction

    • Align self

Flex

Flex Basic

컨테이너들을 가로 또는 세로로 정렬을 쉽게 도와준다. .

  • display

  • justify-content

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
   <style>
       body{
           display: flex;
           justify-content: space-between;
      }
       .box {
          width: 300px;
          height: 300px;
          background-color: red;
          border: 1px solid white;
      }
   </style>
</head>
<body>    
   <div class="box"></div>
   <div class="box"></div>
   <div class="box"></div>
</body>
</html>

Main Axis and Cross Axis

Main Axisjustify-content, Cross Axisalign-items

Flex 에서는 기본 flex-directionrow (가로) 로 되어 있다.

  • Flex-direaction 가 row 일 경우

  • justify-content 는 가로축

  • align-items는 세로축

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
   <style>
       .father{
           display: flex;
           flex-direction: column; /* or row */
           justify-content: space-around;
           align-items: center;
           height: 100vh;
      }
       .box {
          width: 300px;
          height: 300px;
          background-color: red;
          border: 1px solid white;
      }
   </style>
</head>
<body>    
   <div class="father">
       <div class="box"></div>
       <div class="box"></div>
       <div class="box"></div>
   </div>
</body>
</html>



Flex Wrap And Direction

wrap

화면이 축소될 경우 레이아웃이 커지면서 차지하게 된다. 즉 늘어나게 되는 것이다.


이걸 방지 하기 위해서 wrap 속성을 추가할 수 있다.

기본 옵션은 nowrap이다.

wrap 적용시 나오는 화면은 아래와 같다.



flex-direction

flex-direction을 row-reverse 로 주는 경우 반대 방향으로 바꿀 수 있다.

그럴 경우 justify-content 도 영향을 주니 참고하자.

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
   <style>
       .father{
           display: flex;          
           flex-wrap: wrap;
           justify-content: flex-start;            
           height: 100vh;
           background-color: bisque;
           flex-direction: row-reverse
      }
       .box {
          width: 300px;
          height: 300px;
          background-color: red;
          border: 1px solid white;
          color:white;
          font-size: 50px;
          display: flex;
          justify-content: center;
          align-items: center;
      }
   </style>
</head>
<body>    
   <div class="father">
       <div class="box">1</div>
       <div class="box">2</div>
       <div class="box">3</div>
   </div>
</body>
</html>



Align self

chidren 에게 따로 이동을 줄 수 있는 방법 중 하나.

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>Document</title>
   <style>
       .father{
           display: flex;          
           flex-wrap: wrap;
           justify-content: flex-start;            
           height: 100vh;
           background-color: bisque;            
      }
       .box {
          width: 300px;
          height: 300px;
          background-color: red;
          border: 1px solid white;
          color:white;
          font-size: 50px;
          display: flex;
          justify-content: center;
          align-items: center;
      }

       .box:first-child {
           align-self: flex-end;
      }
   </style>
</head>
<body>    
   <div class="father">
       <div class="box">1</div>
       <div class="box">2</div>
       <div class="box">3</div>
   </div>
</body>
</html>


도커 & 쿠버네티스 5주차

부산에서 매주 진행되는 스터디입니다.

부산에서 다른 스터디 내용을 보실려면 카페 에서 보실수 있습니다.

https://www.udemy.com/docker-and-kubernetes-the-complete-guide 을 공부하고 있습니다.

1주차 스터디

2주차 스터디

3주차 스터디

4주차 스터디

5주차 스터디 공부 내용

이번 차에서는 좀 더 복잡한 형태의 서비스를 구성해볼 예정이다.

인덱스를 넣으면 해당되는 피보나치 수열을 계산하는 출력해주는 앱을 개발할 예정이다.

화면 구성

화면은 React 로 작성될 예정이며 아래와 같이 구성된다.

  • 인덱스를 입력받는다.

  • Indicies I hava seen 에서는 지금까지 입력받은 인덱스를 저장해서 보여준다. ( by postgres db)

  • 마지막 Worker 라는 로직을 통해서 수열이 계산되어 지고 그 값이 Redis 에 저장되서 보여주게 된다.

서비스 구성


  • Nginx` 서버를 통해서 접근된다.

  • 프론트는 React Server 로 프록시 된다.

  • API 서버는 Express Server 로 프록시 된다.

  • Express ServerRedis 로 값을 호출하지만 있는 경우 바로 리턴이 되겠지만 없는 경우 Worker 를 통해서 계산된 값을 다시 가져와서 저장하고 리턴을 한다.

  • 마지막으로 유저가 입력한 index은 저장을 postgres 통해서 한다.

이후에 진행되는 https://github.com/bear2u/docker-study2 여기에서 받을수 있다.

소스 구성

Worker

피보나치 수열을 계산하는 로직이 담긴 서비스를 개발한다.

  • redis 추가시 구독

  • 새로운 값이 입력되는 경우 fib(index) 함수를 통해서 값을 계산해서 다시 redis 에 저장한다.

keys.js

module.exports = {
 redisHost: process.env.REDIS_HOST,
 redisPort: process.env.REDIS_PORT
};

index.js

const keys = require('./keys');
const redis = require('redis');

const redisClient = redis.createClient({
 host: keys.redisHost,
 port: keys.redisPort,
 retry_strategy: () => 1000
});
const sub = redisClient.duplicate();

function fib(index) {
 if (index < 2) return 1;
 return fib(index - 1) + fib(index - 2);
}

sub.on('message', (channel, message) => {
 redisClient.hset('values', message, fib(parseInt(message)));
});
sub.subscribe('insert');

package.json

{
 "dependencies": {
   "nodemon": "1.18.3",
   "redis": "2.8.0"
},
 "scripts": {
   "start": "node index.js",
   "dev": "nodemon"
}
}

Server

keys.js

  • 설정값은 추후 도커 환경변수로 입력받게 된다.

module.exports = {
 redisHost: process.env.REDIS_HOST,
 redisPort: process.env.REDIS_PORT,
 pgUser: process.env.PGUSER,
 pgHost: process.env.PGHOST,
 pgDatabase: process.env.PGDATABASE,
 pgPassword: process.env.PGPASSWORD,
 pgPort: process.env.PGPORT
};

index.js

  • express 서버 사용

  • postgres 호출

  • redis 호출

  • api 호출 따른 restful 작성

const keys = require('./keys');

// Express App Setup
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(bodyParser.json());

// Postgres Client Setup
const { Pool } = require('pg');
const pgClient = new Pool({
 user: keys.pgUser,
 host: keys.pgHost,
 database: keys.pgDatabase,
 password: keys.pgPassword,
 port: keys.pgPort
});
pgClient.on('error', () => console.log('Lost PG connection'));

pgClient
.query('CREATE TABLE IF NOT EXISTS values (number INT)')
.catch(err => console.log(err));

// Redis Client Setup
const redis = require('redis');
const redisClient = redis.createClient({
 host: keys.redisHost,
 port: keys.redisPort,
 retry_strategy: () => 1000
});
const redisPublisher = redisClient.duplicate();

// Express route handlers

app.get('/', (req, res) => {
 res.send('Hi');
});

app.get('/values/all', async (req, res) => {
 const values = await pgClient.query('SELECT * from values');

 res.send(values.rows);
});

app.get('/values/current', async (req, res) => {
 redisClient.hgetall('values', (err, values) => {
   res.send(values);
});
});

app.post('/values', async (req, res) => {
 const index = req.body.index;

 if (parseInt(index) > 40) {
   return res.status(422).send('Index too high');
}

 redisClient.hset('values', index, 'Nothing yet!');
 redisPublisher.publish('insert', index);
 pgClient.query('INSERT INTO values(number) VALUES($1)', [index]);

 res.send({ working: true });
});

app.listen(5000, err => {
 console.log('Listening');
});

package.json

{
 "dependencies": {
   "express": "4.16.3",
   "pg": "7.4.3",
   "redis": "2.8.0",
   "cors": "2.8.4",
   "nodemon": "1.18.3",
   "body-parser": "*"
},
 "scripts": {
   "dev": "nodemon",
   "start": "node index.js"
}
}

Client

  • Routing 을 통해서 OtherPage.js 호출

  • react-create-app 을 통해서 설치

  • Fib.js, App.js, Otherpage.js 참고

Fib.js

import React, { Component } from 'react';
import axios from 'axios';

class Fib extends Component {
 state = {
   seenIndexes: [],
   values: {},
   index: ''
};

 componentDidMount() {
   this.fetchValues();
   this.fetchIndexes();
}

 async fetchValues() {
   const values = await axios.get('/api/values/current');
   this.setState({ values: values.data });
}

 async fetchIndexes() {
   const seenIndexes = await axios.get('/api/values/all');
   this.setState({
     seenIndexes: seenIndexes.data
  });
}

 handleSubmit = async event => {
   event.preventDefault();

   await axios.post('/api/values', {
     index: this.state.index
  });
   this.setState({ index: '' });
};

 renderSeenIndexes() {
   return this.state.seenIndexes.map(({ number }) => number).join(', ');
}

 renderValues() {
   const entries = [];

   for (let key in this.state.values) {
     entries.push(
       <div key={key}>
         For index {key} I calculated {this.state.values[key]}
       </div>
    );
  }

   return entries;
}

 render() {
   return (
     <div>
       <form onSubmit={this.handleSubmit}>
         <label>Enter your index:</label>
         <input
           value={this.state.index}
           onChange={event => this.setState({ index: event.target.value })}
         />
         <button>Submit</button>
       </form>

       <h3>Indexes I have seen:</h3>
      {this.renderSeenIndexes()}

       <h3>Calculated Values:</h3>
      {this.renderValues()}
     </div>
  );
}
}

export default Fib;

OtherPage.js

import React from 'react';
import { Link } from 'react-router-dom';

export default () => {
 return (
   <div>
     Im some other page
     <Link to="/">Go back to home page!</Link>
   </div>
);
};

App.js

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import { BrowserRouter as Router, Route, Link } from 'react-router-dom';
import OtherPage from './OtherPage';
import Fib from './Fib';

class App extends Component {
 render() {
   return (
     <Router>
       <div className="App">
         <header className="App-header">
           <img src={logo} className="App-logo" alt="logo" />
           <h1 className="App-title">Welcome to React</h1>
           <Link to="/">Home</Link>
           <Link to="/otherpage">Other Page</Link>
         </header>
         <div>
           <Route exact path="/" component={Fib} />
           <Route path="/otherpage" component={OtherPage} />
         </div>
       </div>
     </Router>
  );
}
}

export default App;

위의 모든 소스는 여기에서 받을수 있다.

자 그럼 실제 이러한 서비스들을 각각의 도커로 만들어서 묶어서 연결하는지를 공부해도록 하자. 기대되는 순간이 아닐수 없다.

도커 구성

Complex 폴더

자 그럼 이제 도커 세팅을 해보자. 우선 기본 환경은 세개의 폴더로 구성된 루트에서 작업이 시작될 예정이다.

complex 폴더

Client Dockerfile.dev

Client > Dockerfile.dev

FROM node:alpine
WORKDIR '/app'
COPY './package.json' ./
RUN npm install
COPY . .
CMD ["npm", "run", "start"]

이게 낯설다면 이전 주차들을 다시 공부하도록 하자.

> docker build -f Dockerfile.dev .
....
> docker run 도커 아이디

Server Dockerfile.dev

Server > Dockerfile.dev

FROM node:alpine
WORKDIR "/app"
COPY ./package.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
> docker build -f Dockerfile.dev .
....
> docker run 도커 아이디

커넥션 오류가 나올것이다. 아직 DB를 올린게 아니라서 그렇다.

Worker

workder > Dockerfile.dev

FROM node:alpine
WORKDIR "/app"
COPY ./package.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

Dcoker-Compose

우리가 구성할 건 다음과 같은 형태를 지닌다.

그러면 하나씩 구성해보도록 하자. 마지막에 설정한 환경변수는 각각의 폴더내 keys.js 에 들어갈 내용들이다.

module.exports = {
 redisHost: process.env.REDIS_HOST,
 redisPort: process.env.REDIS_PORT,
 pgUser: process.env.PGUSER,
 pgHost: process.env.PGHOST,
 pgDatabase: process.env.PGDATABASE,
 pgPassword: process.env.PGPASSWORD,
 pgPort: process.env.PGPORT
};

docker-compose.yml

dockerfile들을 한번에 순서대로 만들기 위한 통합파일을 만들어준다. 위치는 root 에서 만든다.

docker-compose.yml

  • Postgres

    version: '3'
    services:
    postgres:
      image: 'postgres:latest'
    > docker-compose up

  • redis

    redis:
      image: 'redis:latest'
    docker-compose up

  • server

    • Specify build

      build:
        dockerfile: Dockerfile.dev
        context: ./server
    • Specify volumes

      volumes:
          - /app/node_modules
          - ./server:/app
    • Specify env variables

      • 환경 설정시 variableName 지정을 안 하는 경우 현재 시스템에 있는 변수로 적용된다.

          # Specify env variables
        environment:
          - REDIS_HOST:redis
          - REDIS_PORT:6379
          - PGUSER:postgres
          - PGHOST:postgres
          - PGDATABASE:postgres
          - PGPASSWORD:postgres_password
          - PGPORT:5432
  • Client

      client:
      build:
        dockerfile: Dockerfile.dev
        context: ./client
      volumes:
        - /app/node_modules
        - ./client:/app  
  • worker

      worker:
      build:
        dockerfile: Dockerfile.dev
        context: ./client    
      volumes:
        - /app/node_modules
        - ./worker:/app

전체 소스는 다음과 같다.

docker-compose.yml

version: '3'
services:
postgres:
  image: 'postgres:latest'
redis:
  image: 'redis:latest'  
server:
  # Specify build  
  build:
    dockerfile: Dockerfile.dev
    context: ./server      
  # Specify volumes
  volumes:
    - /app/node_modules
    - ./server:/app
  # Specify env variables
  environment:
    - REDIS_HOST:redis
    - REDIS_PORT:6379
    - PGUSER:postgres
    - PGHOST:postgres
    - PGDATABASE:postgres
    - PGPASSWORD:postgres_password
    - PGPORT:5432
client:
  build:
    dockerfile: Dockerfile.dev
    context: ./client    
  volumes:
    - /app/node_modules
    - ./client:/app    
worker:
  build:
    dockerfile: Dockerfile.dev
    context: ./client    
  volumes:
    - /app/node_modules
    - ./worker:/app      

nginx

Proxy 설정을 해서 프론트와 백단을 분리를 해보자.

nginx docker 이미지에 기본 설정을 clientserver 를 추가해서 proxy 해주도록 하자.

nginx/default.conf


upstream client {
  server client:3000;
}

upstream server {
  server server:5000;
}

server {
  listen 80;

  location / {
      proxy_pass http://client;
  }

  location /api {
      rewrite /api/(.*) /$1 break;
      proxy_pass http://server;
  }
}
  • rewrite /api/(.*) /$1 break;/api 로 들어오는 경우 내부에서 다시 / 로변경해주기 위함

  • client 는 3000 포트로 내부에서 proxy 되며

  • server 는 5000 포트로 내부에서 proxy 된다.

  • 외부에서는 80 포트만으로 제어한다.

DockerFile.dev 생성

nginx/Dockerfile.dev

FROM nginx
COPY ./default.conf /etc/nginx/conf.d/default.conf
  • nginx 도커 이미지를 가져온다.

  • 기본 설정 default.conf 파일을 도커 이미지내 설정으로 덮어쓴다.

docker-compose 에 nginx 추가

  • nginx 폴더내 Dockerfile.dev 를 참조해서 생성한다.

  • localhost는 3050으로 설정하고 nginx 도커는 80 으로 바인딩 해준다.

  • 문제가 생길 경우 자동으로 재실행을 해준다.

  nginx:
  restart: always
  build:
    dockerfile: Dockerfile.dev
    context: ./nginx  
  ports:
    - '3050:80'  

docker-compose 실행

이제 잘되는지 실행을 해보자. 모든 도커 파일을 다시 만들도록 하자.

docker-compose up --build

서버가 정상적으로 실행된 것 확인하기 위해선 진입점을 확인해야 한다.

이전에 nginx 서버는 3050포트와 바인딩된 상태이다.

    ports:
    - '3050:80'

그럼 이제 확인해본다.

http://localhost:3050

React 서버 실행

정상적으로 React 서버가 뜨는 걸 볼수 있다.

하지만 개발자 도구에 콘솔을 열어보면 웹소켓 문제가 보인다.

실시간 연동이 안될 뿐이지 다시 새로고침을 해보면 정상적으로 저장된 걸 볼수 있다.

  • 5번째 피보나치 수는 8이다.

그러면 nginx 서버에서 웹소켓을 설정해서 실시간 반영이 되도록 해보자.

nginx>default.conf

location /sockjs-node {
  proxy_pass http://client;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "Upgrade";
}

Dockerfile 을 새로 빌드해서 up 을 해보자.

정상적으로 보여지는 걸 볼수 있다.

문제점

  1. 만약 submit 을 했는데 실시간으로 변경이 안되는 경우

client/Fib.js 에 다음과 같은 코드를 넣어야 한다.

componentDidMount() {
setInterval(() => {
  this.fetchValues();
  this.fetchIndexes();
}, 1000)
}
  1. docker-compose up 을 할때 오류가 나면 다시 up 을 해주자.

  2. window에서 종종 오류가 나기 때문에 mac이나 리눅스에서 하길 추천한다.

여기까지 소스는 Github 에서 받을 수 있다.

이상으로 5주차 도커&쿠버네이트 수업 정리를 마치도록 한다.

Flutter Youtube 화면 개발

2주차 스터디는 Youtube Api를 활용해서 화면 구성을 공부해보는 시간을 가져봅니다.

이번시간에 배우는 부분은 아래와 같습니다.

  • Youtube Api 조회

  • Json decoding -> dart class 로 변환하는 방법

  • FutureBuilder 로 비동기로 위젯 구성하기

우선 프로젝트를 기본적으로 구성을 해봅니다.

main.dart

  • material 테마를 가져옵니다.

  • src/app.dart 를 import 해서 패키징 합니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/app.dart';

void main() => runApp(App());

app.dart

  • Theme 설정합니다.

  • home.dart 를 import 해서 런칭 위젯 설정해줍니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/home.dart';

class App extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     theme: ThemeData(
       primarySwatch: Colors.red
    ),
     home: Home(),
  );
}
}

Home Widget

Home 위젯은 탭이 포함된 메인 을 말합니다.

탭은 총 3개로 구성될 예정입니다. 매주 공부할 내용을 기반으로 하나씩 채워나가면서 만들어 볼 예정입니다.

  • Youtube ( 2주차 )

  • Chat ( 3주차 )

  • Nearby ( 4주차 )

Home 위젯은 탭이 바뀌면 setState를 호출해서 변해야 해서 statefulwidget 으로 만듭니다.

class Home extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => HomeState();
}

class HomeState extends State<Home>{
  ...
}

내용은 크게 appbar, body, bottomNavigationBar 가 포함되어 있습니다.

@override
Widget build(BuildContext context) {
 return Scaffold(
   appBar: _buildMatchAppbar(context), //1
   body: _tabs[_tabIndex], //2
   bottomNavigationBar: _buildBottomNavigationBar(context), //3
);
}
  1. AppBar를 커스텀 해서 만들어줍니다.

      Widget _buildMatchAppbar(context) {
       return AppBar(
         title: Text(
             "Flutter Study App",
             style: TextStyle(color: AppBarColor),
        ),
         backgroundColor: AppBackgroundColor,
      );
    }
  2. 3개의 화면 위젯을 미리 만들어서 리스트로 호출하고 있습니다.

   var _tabIndex = 0;
  final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];
  1. BottomNavigationBar 라고 flutter 에서 미리 만들어서 제공해주는 탭바 비슷한게 있습니다. Scaffold 기본 위젯에 포함되어 있어서 생성 후 바로 사용이 가능합니다.

    _buildBottomNavigationBar(context) => Theme(
       data: Theme.of(context).copyWith(
         canvasColor: AppBackgroundColor,
         primaryColor: BottomNaviItemSelectedColor,
         iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
         textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
           color: BottomNaviItemColor
        )),
      ),
       child: BottomNavigationBar(
         items: <BottomNavigationBarItem>[
           _bottomNavigationBarItem(Icons.videocam, "youtube"),
           _bottomNavigationBarItem(Icons.chat, "chat"),
           _bottomNavigationBarItem(Icons.map, "nearby")
        ],
         type: BottomNavigationBarType.fixed,
         currentIndex: _tabIndex,
         onTap: (int index) {        
           setState(() {
             _tabIndex = index;
          });
        },
      ));


    BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
     return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
    }

    탭 이벤트가 발생시 onTap 함수를 통해서 setState 호출해서 body 를 변경하고 있습니다.

    onTap: (int index) {        
       setState(() {
       _tabIndex = index;
    });

Home.dart 전체 소스는 다음과 같습니다.

import 'package:flutter/material.dart';

import 'package:youtube_clone_app/src/tabs/youtubeScreen.dart';
import 'package:youtube_clone_app/src/commons/colors.dart';


import 'package:youtube_clone_app/src/tabs/NearByScreen.dart';
import 'package:youtube_clone_app/src/tabs/ChatScreen.dart';

class Home extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => HomeState();
}

class HomeState extends State<Home>{

 var _tabIndex = 0;
 final List<Widget> _tabs = [YoutubeScreen(), ChatScreen(), NearByScreen()];

 @override
 void initState() {
   // TODO: implement initState
   super.initState();

}
 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: _buildMatchAppbar(context),
     body: _tabs[_tabIndex],
     bottomNavigationBar: _buildBottomNavigationBar(context),
  );
}

 Widget _buildMatchAppbar(context) {
   return AppBar(
     title: Text(
         "Flutter Study App",
         style: TextStyle(color: AppBarColor),
    ),
     backgroundColor: AppBackgroundColor,
  );
}

 _buildBottomNavigationBar(context) => Theme(
     data: Theme.of(context).copyWith(
       canvasColor: AppBackgroundColor,
       primaryColor: BottomNaviItemSelectedColor,
       iconTheme: Theme.of(context).iconTheme.copyWith(color: BottomNaviItemColor),
       textTheme: Theme.of(context).textTheme.copyWith(caption: TextStyle(
         color: BottomNaviItemColor
      )),
    ),
     child: BottomNavigationBar(
       items: <BottomNavigationBarItem>[
         _bottomNavigationBarItem(Icons.videocam, "youtube"),
         _bottomNavigationBarItem(Icons.chat, "chat"),
         _bottomNavigationBarItem(Icons.map, "nearby")
      ],
       type: BottomNavigationBarType.fixed,
       currentIndex: _tabIndex,
       onTap: (int index) {          
         setState(() {
           _tabIndex = index;
        });
      },
    ));


 BottomNavigationBarItem _bottomNavigationBarItem(IconData icon, String text) {
   return new BottomNavigationBarItem(icon: Icon(icon), title: Text(text));
}
}

Youtube Widget

youtube 위젯도 동적으로 리스트를 가져와야 해서 Stateful 위젯으로 구성합니다.

class YoutubeScreen extends StatefulWidget {
 @override
 _YoutubeState createState() => _YoutubeState();
}

class _YoutubeState extends State<YoutubeScreen> with AutomaticKeepAliveClientMixin<YoutubeScreen>{
 @override
 bool get wantKeepAlive => true;
}
  • AutomaticKeepAliveClientMixin 은 탭이 변경시 유지를 해준다고 하는데 지금 동작이 잘 안되고 있어서 다시 살펴봐야 할것으로 보입니다.

    • bool get wantKeepAlive => true; 을 설정해줘야 합니다.

    • build 에서 부모를 super.build 해줘야 합니다.

      @override
      Widget build(BuildContext context) {
       super.build(context);
       return _createListBuilder();
      }

리스트 구성

Build 함수내에 ListBuilder() 를 통해서 리스트를 구성해봅니다.

...
return _createListBuilder();
...
//1
_createListBuilder()  => FutureBuilder(
 //2
 future: _getVideos(),
 //3
 builder: (BuildContext context, AsyncSnapshot snapshot){
   //4  
   switch(snapshot.connectionState) {
     case ConnectionState.none:
     case ConnectionState.waiting:
       //5    
       return Center(
         child: CircularProgressIndicator(),
      );
     //6      
     default :
       var list = snapshot.data;
       return ListView.builder(
           itemCount: list.length,
           itemBuilder: (BuildContext context, int index) => _buildListItem(context, list[index])
      );
  }
},
);
  1. FutureBuilder를 통해서 비동기로 youtube api를 조회해서 결과값이 리턴되는 경우 snapshot 으로 콜백이 다시 들어옵니다.

  2. _getVideos() 함수를 통해서 api 를 호출합니다.

  3. 빌더 함수를 통해서 상태제어를 할수 있습니다.

  4. 기본적으로 처음 호출시 connectionStatewaiting 상태가 됩니다. 그리고 결과가 리턴되면 done 으로 바뀌어서 다시 들어오게 됩니다.

  5. 데이터 가져오는 동안 중간에 Progress 를 올려서 ProgressBar 올려서 볼수 있습니다.

  6. snapshot.data는 추후에 나올 함수 list 로 리턴받게 됩니다.

  7. ListView.builder 를 통해서 리스트를 만들고 있습니다.

  8. _buildListItem 를 통해서 리스트내 각각의 리스트 아이템을 만들어주고 있습니다.

리스트 아이템 구성

_buildListItem을 통해서 구성이 됩니다. 전체적인 트리구조는 다음과 같습니다.


_buildListItem(context, video) {
 return InkWell(
   onTap: () {
     Scaffold.of(context).showSnackBar(SnackBar(content: Text("clicked : $video")));
  },
   child: Container(
     decoration: BoxDecoration(
       border: Border(bottom: BorderSide(color: BorderColor)),
    ),
     child: Padding(
         padding: const EdgeInsets.all(10.0),
         child: Flex(
           direction: Axis.vertical,
           children: <Widget>[
             _buildItemVideoThumbnail(video),
             _myTubeVideoContent(video)
          ],
        )
    ),
  ),
);
  • Thumbnail 이미지와 그 밑에 내용으로 크게 구성되어 있습니다.

Thumbnail 이미지 구성

썸네일은 FadeIn 되는 구조를 가집니다.

  _buildItemVideoThumbnail(VideoData video) => AspectRatio(
   aspectRatio: 1.8,
   child: FadeInImage.memoryNetwork(
       placeholder: kTransparentImage,
       image: video.getThumbnailUrl,
       fit: BoxFit.cover
  )
);
  • VideoData 라는 데이터 클래스를 통해서 썸네일 이미지를 설정해주고 있습니다.

  • AspectRatio 을 통해서 종횡비 비율을 조절하고 있습니다.

  • kTransparentImage placeHolder 를 통해서 fadeIn 되는 효과를 만들수 있습니다.

    import 'dart:typed_data';

    final Uint8List kTransparentImage = new Uint8List.fromList(<int>[
     0x89,
     0x50,
     0x4E,
     0x47,
     0x0D,
     0x0A,
     0x1A,
     0x0A,
     0x00,
     0x00,
     0x00,
     0x0D,
     0x49,
     0x48,
     0x44,
     0x52,
     0x00,
     0x00,
     0x00,
     0x01,
     0x00,
     0x00,
     0x00,
     0x01,
     0x08,
     0x06,
     0x00,
     0x00,
     0x00,
     0x1F,
     0x15,
     0xC4,
     0x89,
     0x00,
     0x00,
     0x00,
     0x0A,
     0x49,
     0x44,
     0x41,
     0x54,
     0x78,
     0x9C,
     0x63,
     0x00,
     0x01,
     0x00,
     0x00,
     0x05,
     0x00,
     0x01,
     0x0D,
     0x0A,
     0x2D,
     0xB4,
     0x00,
     0x00,
     0x00,
     0x00,
     0x49,
     0x45,
     0x4E,
     0x44,
     0xAE,
    ]);

하단 화면 구성하기

각각의 리스트 아이템에서 하단 화면을 구성해봅니다.

/// Video Content View builder at ListView Item Widget
 _myTubeVideoContent(VideoData video) => Container(
   alignment: Alignment.topCenter,
   margin: EdgeInsets.only(top: 10.0),
     //1
   child: Row(
     children: <Widget>[
       Container(
         margin: EdgeInsets.only(right: 10.0),
           //2
         decoration: BoxDecoration(
             shape: BoxShape.circle,
             color: BorderColor,
             image: DecorationImage(
               image: NetworkImage(video.getChannelData.getThumbnailUrl),
               fit: BoxFit.contain,
            )
        ),
         width: 32.0,
         height: 32.0,
      ),
         //3
       Flexible(
         child: Column(
           children: <Widget>[
             Container(
               alignment: Alignment.centerLeft,
               child: Text(
                   video.getTitle,
                   maxLines: 2,
                   style: TextStyle(
                       fontWeight: FontWeight.w400,
                       fontSize: 16.0,
                       color: TextColor
                  ),
                   overflow: TextOverflow.ellipsis,
                   textAlign: TextAlign.left),
            ),
               //4
             Container(
               alignment: Alignment.centerLeft,
               child: Text(
                 "${video.getChannelData.getName}",
                 maxLines: 2,
                 textAlign: TextAlign.left,
                 style: TextStyle(color: TextColor,),
              ),
            ),
          ],
           mainAxisAlignment: MainAxisAlignment.start,
        ),
         flex: 1,
      ),
         //5
       InkWell(
           child: Container(child: Icon(Icons.more_vert, size: 20.0, color: BorderColor),),
           onTap: _modalBottomSheet,
           borderRadius: BorderRadius.circular(20.0)
      )
    ],
  ),
);

  1. 하단은 프로필 써클 이미지와 기타 설명으로 구성되어 있습니다. 세로로 구성되어 지기 때문에 Row 로 설정합니다.

  2. 써클 이미지 구성은 BoxDecoration 위젯 안에 NetworkImage 를 가져옵니다.

    image: DecorationImage(
       image: NetworkImage(video.getChannelData.getThumbnailUrl),
       fit: BoxFit.contain,
    )
  3. 3,4번은 설명글을 구성합니다. Column 을 통해서 가로로 설정합니다.

    1. 마지막으로 InkWell을 통해 Ripple 효과를 줘서 더보기 버튼을 구성해봅니다.

더보기 클릭

_modalBottomSheet

바텀시트를 올려서 메뉴를 볼수 있습니다.

_modalBottomSheet() => showModalBottomSheet(
     context: context,
     builder: (builder) => Container(
       color: AppBackgroundColor,
       child: new Column(
         children: <Widget>[
           _bottomSheetListTile(Icons.not_interested, "관심 없음", () => debugPrint("관심 없음")),
           _bottomSheetListTile(Icons.access_time, "나중에 볼 동영상에 추가", () => debugPrint("나중에 볼 동영상에 추가")),
           _bottomSheetListTile(Icons.playlist_add, "재생목록에 추가", () => debugPrint("재생목록에 추가")),
           _bottomSheetListTile(Icons.share, "공유", () => debugPrint("공유")),
           _bottomSheetListTile(Icons.flag, "신고", () => debugPrint("신고")),
           Container(
             decoration: new BoxDecoration(
                 border: new Border(top: new BorderSide(color: BorderColor))
            ),
             child: _bottomSheetListTile(Icons.close, "취소", () => Navigator.pop(context)),
          )
        ],

      ),
    )
);

 /// list tile builder for BottomSheet
 _bottomSheetListTile(IconData icon, String text, Function onTap) =>
     ListTile(
         leading: Icon(icon, color: TextColor),
         title: Text(text, style: TextStyle(color: TextColor),),
         onTap: onTap
    );

마지막으로 비디오를 가져오는 부분을 추가해서 데이터를 갱신할 수 있도록 해줍니다.

Future<List<VideoData>> _getVideos() async {
   List<VideoData> videoDataList = new List<VideoData>();
   String dataURL = "https://www.googleapis.com/youtube/v3/videos?chart=mostpopular&regionCode=KR"
       "&maxResults=20&key=$youtubeApiKey&part=snippet,contentDetails,statistics,status";

   http.Response response = await http.get(dataURL);
   dynamic resBody = json.decode(response.body);
   List videosResData = resBody["items"];

   videosResData.forEach((item) => videoDataList.add(new VideoData(item)));

   for (var videoData in videoDataList) {
     String channelDataURL = "https://www.googleapis.com/youtube/v3/channels?key=$youtubeApiKey&part=snippet&id=${videoData.getOwnerChannelId}";

     http.Response channelResponse = await http.get(channelDataURL);
     dynamic channelResBody = json.decode(channelResponse.body);

     videoData.channelDataFromJson = channelResBody["items"][0];
  }

   return videoDataList;
}

전체 리스트를 가져온다음 각각 채널 정보를 다시 호출하기 때문에 비효율적으로 보이지만 스터디 목적이기 때문에 따로 리팩토링은 하지 않겠습니다.

JSON Decoding

미리 VideoData 와 ChannelData 클래스를 만들어서 Json decoding 해줄수 있습니다.

다른 방법도 있지만 여기선 기본적인 방법으로 설명합니다.

VideoDart.dart

import './ChannelData.dart';

class VideoData {
 String ownerChannelId;
 String thumbnailUrl;
 String title;
 String viewCount;
 String publishDate;
 ChannelData channelData;

 /// Video Data Constructor
 VideoData(Map videoJsonData) {
   this.ownerChannelId = videoJsonData["snippet"]["channelId"];
   this.thumbnailUrl = videoJsonData["snippet"]["thumbnails"]["high"]["url"];
   this.title = videoJsonData["snippet"]["title"];
   this.viewCount = videoJsonData["statistics"]["viewCount"];
   this.publishDate = videoJsonData["snippet"]["publishedAt"];
}

 /// Video Data unnamed Constructor
 VideoData.includeChannelData(Map videoJsonData, Map channelJsonData) {
   VideoData(videoJsonData);
   this.channelData = ChannelData(channelJsonData);
}

 set channelDataFromJson(Map channelJsonData) {
   this.channelData = new ChannelData(channelJsonData);
}

 get getOwnerChannelId => this.ownerChannelId;

 get getThumbnailUrl => this.thumbnailUrl;

 get getTitle => this.title;

 get getViewCount => this.viewCount;

 get getPublishedDate => this.publishDate;

 get getChannelData => this.channelData;
}

ChannelData.dart

class ChannelData {
 String name;
 String thumbnailUrl;

 ChannelData(Map channelJsonData) {
   this.name = channelJsonData["snippet"]["title"];
   this.thumbnailUrl = channelJsonData["snippet"]["thumbnails"]["default"]["url"];
}

 get getName => this.name;

 get getThumbnailUrl => this.thumbnailUrl;
}

전체적인 화면 트리는 다음과 같습니다.


여기까지 2주차 유튜브 리스트 개발이었습니다.

개선해야 될 내용

  • 탭이 변경시 계속 갱신되는 문제

  • 좀 더 효율적으로 API 를 호출하는 부분

그럼 다음 주차에는 카카오톡 같은 메신징 화면을 Firebase 연동해서 개발해보도록 하겠습니다.

참석해주셔서 감사합니다.

adb shell screencap -p /sdcard/screen.png
adb pull /sdcard/screen.png
adb shell rm /sdcard/screen.png

출처 : https://blog.shvetsov.com/2013/02/grab-android-screenshot-to-computer-via.html

Docker&Kubernetes 4주차 스터디 정리

스터디 내용

  • Github 배포

  • Travis CI 환경설정 추가 및 구성

  • ec2 elastic beantalk 구성 및 자동 배포

개발 flow

준비물

그럼 시작해보자.

Github 설정

  1. 깃헙 레포를 만든다.

  2. 현재 소스를 깃헙 레포와 연결한다.

  3. 깃헙에 푸시한다.

git init
git add .
git commit -m "first commit"
git remote add origin https://github.com/bear2u/docker-react2.git
git push -u origin master

기존에 없는 경우 시작점을 여기 클론해서 진행이 가능하다.

Github에 푸시를 했으면 정상적으로 파일이 다 올라간 걸 확인할 수 있다.

Travis 설정

https://travis-ci.org/ 에 가서 깃헙에 연동해서 로그인을 하자.

설정에 가면 내가 가진 모든repository 가 나올텐데 거기에서 금방 올린 docker-react2 라는 repo를 검색해서 활성화를 해준다.

그럼 docker-react2 를 클릭하면 처음 연동을 하면 비어있는 빌드 목록을 볼수 있을 것이다.

그럼 순서는 어떻게 이루어지는 지 살펴보자.

  1. Travis에게 우리는 동작되는 도커의 카피를 하라고 말한다.

  2. 그리고 Dockerfile.dev를 통해서 이미지를 만들고

  3. Travis에게 테스팅을 돌려서 문제가 없는지 체크

  4. 마지막으로 Travis -> Aws E/B 에게 배포를 말함

이제 소스에서 Travis 환경설정 파일을 만들어 주면 된다.

  • 파일이름 : .travis.yml

#1
sudo: required
#2
services:
- docker
#3
before_install:
- docker build -t bear2u/docker-react -f Dockerfile.dev .
#4
script:
- docker run bear2u/docker-react npm run test -- --coverage
  1. 관리자 권한 필요하다.

  2. 서비스를 어떤 게 필요한지를 미리 정의

  3. 사전 설치

    1. 현재 위치에서 Dockerfile.dev를 이용해서 빌드한다.

  4. docker test을 하는데 커버리지를 지정한다.

그럼 이제 일단 테스팅이 잘 되는지 살펴보자.

git add .
git commit -m 'added travis config'
git push origin master

푸시가 되면 travis 에서 자동적으로 시스템이 시작된다.

정상적으로 테스팅 결과가 나오는 지 확인해본다.

AWS Elastic Beanstalk 설정

AWS Elastic Beanstalk은 도커나 가상 앱을 EC2나 S3를 통해서 바로 올릴수 있도록 도와주는 서비스인것 같다.

이제 aws 에서 Elastic Beanstalk 서비스를 찾아서 어플리케이션을 만들자.

  • Apllication Name: docker-react

  • Sample App

플랫폼을 docker로 선택해야 한다. 그리고 Create environment

완료가 되는 경우 오른쪽 상단에 링크를 클릭시 샘플 앱이 실행되는 걸 확인할 수 있다.

Travis + aws e/b 설정

그럼 travis 설정에서 aws 에 배포하는 내용을 추가하도록 하자.

  • bucket_name 의 경우 S3의 위치를 뜻한다.

#1
sudo: required
#2
services:
- docker
#3
before_install:
- docker build -t bear2u/docker-react -f Dockerfile.dev .
#4
script:
- docker run bear2u/docker-react npm run test -- --coverage

deploy:
provider: elasticbeanstalk
region: "ap-northeast-2"
app: "docker-react"
env: "DockerReact-env"
bucket_name: "elasticbeanstalk-ap-northeast-2-110932418490"
bucket_path: "docker-react"
on:
  branch: master

aws e/b api 키 설정

Travis 에서 aws에 배포할려면 api 가 필요하다.

이 api 서비스를 이용하기 위해선 aws에서iam 이 필요하다.

유저를 추가해준다.

마지막에 까지 진행시 나오는 액세스키와 secret 키가 중요하기 때문에 꼭 백업해놓자.

Travis 에 Access Key 와 Secret Key 설정

Travis에서 docker-react2 클릭 후 설정에 들어가서 키를 2개를 입력해놓으면 된다. 이 입력해놓은 걸 나중에 travis.yml 에서 변수명으로 가져와서 사용할 수 있는 것이다.

travis.yml 파일에 마지막에 api를 추가해준다.

  access_key_id: $AWS_ACCESS_KEY
secret_access_key:
  secure: "$AWS_SECRET_KEY"

그럼 다시 테스트를 해보자.

git add .
git commit -m 'added aws eb'
git push orgin master

그런데 aws e/b 대쉬보드에서 확인해보면 실패가 뜬다.

이유는 포트를 오픈을 안했다.

포트 개방

  • Dockerfile

  • EXPOSE 80

    FROM node:alpine as builder
    WORKDIR '/app'
    COPY package.json .
    RUN npm install
    COPY . .
    RUN npm run build

    FROM nginx
    EXPOSE 80
    COPY --from=builder /app/build /usr/share/nginx/html

aws에서 성공적으로 올라가졌는지 체크해보자.

링크도 클릭해서 잘 나오는지 확인해보자.

빌드 실패가 계속 나오는 경우

EC2 프리티어중 t2.micro 인스턴스가 주로 npm install 사용시 타임 아웃이 걸리는 문제가 발생될 수 있다.

Dockerfile 에 수정을 이렇게 해보자.

COPY package*.json ./

브랜치 변경

처음 계획은 feature 라는 브랜치에 푸시를 하면 마스터에 머지를 하고 테스팅을 돌려서 배포까지 하는 구조를 말했다.

git checkout -b feature

그리고 소스를 수정하고

  • src/App.js

  • Good Choice React in feature

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';

class App extends Component {
render() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Good Choice React in feature
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}
}

export default App;

새로운 브랜치 Push, PR, Merge

다시 푸시를 한 뒤에 github 에 들어가서 머지를 해보자.

새로운 브랜치를 만들었기 때문에 travis 가 설정하게끔 해줘야 한다.

이제 정상적으로 머지를 하면 된다.

그럼 자동적으로 travis로 신호를 줘서 테스팅 및 배포를 하게 된다.

이제 다시 수정된 화면을 보자.

정상적으로 수정된 내용이 나오는 걸 볼수 있다.

끝나면 삭제를 하면 된다.

오른쪽 위에 메뉴에 삭제를 할수 있다.

여기까지 4주차 도커&쿠버네이트 스터디 정리 내용이다.

Flutter Simple TODO(할일관리) List

첫번째 수업에서는 DartFlutter를 살펴보고 ToDoList App 을 만들어보도록 할 것이다.

이 내용은 부산에서 진행된 Flutter 스터디 1주차를 정리한 내용입니다.

소스는 여기에서 받을수 있습니다.

Stateless 와 Stateful Widget

  • Stateless 는 동적으로 변하지 않는 위젯

  • Stateful 은 동적으로 변할수 있는 위젯

Stateful 구현은

  • createState

  • State 상속된 클래스 구현

모든 Widget은 build 함수를 필수

import 'package:flutter/material.dart';

void main() => runApp(new TodoApp());

class TodoApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
     title: 'Todo List',
     home: new TodoList()
  );
}
}

class TodoList extends StatefulWidget {
 @override
 createState() => new TodoListState();
}

class TodoListState extends State<TodoList> {
 @override
 Widget build(BuildContext context) {
   return new Scaffold(
     appBar: new AppBar(
       title: new Text('Todo List')
    )
  );
}
}

이제 Stateful widget이 된 상태이다. 그럼 TODO 아이템을 추가를 해볼까 한다.

Body 부분 추가

우선 floating action button 을 추가를 해보자.

floatingActionButton: new FloatingActionButton(
       onPressed: _addTodoItem,
       tooltip: '추가',
       child: new Icon(Icons.add)
    ),
  • _addTodoItem 은 클릭시 발생되는 함수를 뜻한다.

  • child는 해당 위젯에 자식으로 들어가서 다시 아이콘을 추가를 한다.

body 에 리스트를 추가를 해보자.

body: _buildTodoList(),
  • _buildTodoList

Widget _buildToDoList() {
   return new ListView.builder(
       itemBuilder: (context, index) {
           if(index < _todoItems.length) {
               return _buildToDoItem(_todoItems[index]);
          }
      }
  );
}

자식 아이템도 추가해준다.

  Widget _buildToDoItem(String todoText) {
   return new ListTile(
     title: Text(todoText),
  );
}

이벤트 등록

그리고 floating button 을 클릭시 아이템을 추가 하기 위해선 이벤트도 등록해줘야 하는데 그건 pressed 에서 해주면 된다.

  • 버튼 클릭시 아이템이 하나씩 추가된다.

onPressed: _addTodoItem,
  _addTodoItem() {
  setState(() {
    int index = _todoItems.length;
    _todoItems.add('Item_$index');
  });
}

전체 소스

import 'package:flutter/material.dart';

void main() => runApp(new TodoApp());

class TodoApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return new MaterialApp(
       title: 'Todo List',
       home: new TodoList()
  );
}
}

class TodoList extends StatefulWidget {
 @override
 createState() => new TodoListState();
}

class TodoListState extends State<TodoList> {

 List<String> _todoItems = [];

 Widget _buildToDoList() {
   return new ListView.builder(
       itemBuilder: (context, index) {
         if(index < _todoItems.length) {
           return _buildToDoItem(_todoItems[index]);
        }
      }
  );
}

 Widget _buildToDoItem(String todoText) {
   return new ListTile(
     title: Text(todoText),
  );
}

 _addTodoItem() {
   setState(() {
     int index = _todoItems.length;
     _todoItems.add('Item_$index');
  });
}

 @override
 Widget build(BuildContext context) {
   return new Scaffold(
       appBar: new AppBar(
           title: new Text('Todo List')
      ),
       body: _buildToDoList(),
       floatingActionButton: new FloatingActionButton(
           onPressed: _addTodoItem,
           tooltip: '추가',
           child: new Icon(Icons.add)
      ),
  );
}
}

아이템 추가하는 화면 등록

이전까지는 한 화면에서 모든걸 진행했다. 이제 새로운 화면을 추가 해보자.

  1. 새로운 Screen 추가

    • 입력되는 문자열을 리턴하는 화면 생성

    import 'package:flutter/material.dart';

    class AddItemScreen extends StatelessWidget {
     @override
     Widget build(BuildContext context) {
       return Scaffold(
         appBar: AppBar(
           title: Text("할일 추가")
        ),
         body: TextField(
           autofocus: true,
           onSubmitted: (val) {
             Navigator.of(context).pop({'item': val});
          },
        )
      );
    }
    }

  2. 버튼 이벤트 함수에 라우팅 추가

    • 라우팅을 통해 이동된 후 값을 리턴받기를 원함

      onPressed: _navigatorAddItemScreen,
      _navigatorAddItemScreen() async {
         Map results = await Navigator.of(context).push(new MaterialPageRoute(
             builder: (BuildContext context) {
                 return AddItemScreen();
            },
        ));

         if(results != null && results.containsKey("item")) {
             _addTodoItem(results["item"]);
        }
      }

  1. 최종적으로 아이템을 리스트에 추가해서 화면 갱신

    _addTodoItem(String item) {
       setState(() {
      _todoItems.add(item);
    });
    }

할일 완료 처리

추가는 이전 시간까지 되는 걸 확인했다.

하지만 삭제는 어떻게 처리해야 할까?

방법은 여러가지가 있겠지만

  • 해당 리스트 항목에 버튼을 추가해서 클릭시 완료 처리한다.

리스트 아이템 마다 클릭시 이벤트를 먼저 등록

Widget _buildToDoItem(String todoText, int index) {
   return new ListTile(
       title: Text(todoText),
       onTap: () => _promptRemoveTodoItem(index),
  );
}

그런 다음 다이얼로그 띄워서 완료 할껀지 물어보자.

_promptRemoveTodoItem(int index) {
   showDialog(
       context: context,
       builder: (BuildContext context) {
         return new AlertDialog(
             title: new Text(' "${_todoItems[index]}" 완료 처리 하시겠습니까?'),
             actions: <Widget>[
               new FlatButton(
                   child: new Text('CANCEL'),
                   onPressed: () => Navigator.of(context).pop()
              ),
               new FlatButton(
                   child: new Text('완료'),
                   onPressed: () {
                     _removeTodoItem(index);
                     Navigator.of(context).pop();
                  }
              )
            ]
        );
      }
  );
}

최종적으로 state 함수를 통해서 완료를 진행

_removeTodoItem(int index) {
   setState(() => _todoItems.removeAt(index));
}

이상으로 오늘 Flutter 1주차 수업 내용 정리했습니다.

도커 & 쿠버네이트 3주차 스터디 정리

이번 시간에는 실제 운영되는 배포까지 실습해보겠다.

소스는 여기에서 확인가능하다.

https://github.com/bear2u/docker3

기본적으로 프로세스는 다음과 같다.

Development -> Testing -> Deployment -> Developm...

  1. 개발자가 소스를 github feature 브랜치에 푸시

  2. 그럼 마스터 브랜치에 PR을 요청하면

  3. 그럼 Travis CI 에서 테스팅을 진행

  4. 테스팅을 진행해서 문제가 없는 경우 마스터 브랜치에 머지

  5. aws에 정상적으로 배포

그럼 실습을 진행해보자. 우선 노드와 React를 이용할 예정이다. 노드 버전을 체크 해보자.

node -v

v8.11.3

만약 설치가 안된 경우 os에 맞는 노드를 설치하자.

그럼 리엑트 프로젝트를 만들어보자.

> npm install -g create-react-app

> create-react-app frontend

그럼 해당 위치에 frontend가 만들어져 있을 것이다.

명령어 정리

  • npm run start : 개발 서버 실행

  • npm run test : 테스트 진행

  • npm run build : production 를 위해 번들링 진행

잘 되는지 보기 위해

npm run start

화면이 잘 나오는지 체크 해보자.

vscode 에디터를 열어서 Dockerfile.dev를 하나 만들자. 이 파일은 개발 전용 도커를 열어서 내부에서 방금 만든 리엑트 서버를 실행하기 위한 테스트 용도 이다.

  • Dockerfile.dev

FROM node:alpine

WORKDIR '/app'

COPY package.json .
RUN npm install

COPY . .

CMD ["npm", "run", "start"]

잠깐 설명을 해보자면

FROM node:alpine
  • alpine 리눅스 os으로 부터 이미지를 가져온다.

WORKDIR '/app'
  • 도커내 os에서 /app 위치를 작업폴더로 지정

COPY package.json .
RUN npm install
  • 로컬 package.json을 복사한다. 그리고 npm install을 한다. 이렇게 하는 이유는 package.json 내용만 변경 될 경우 캐싱을 이용안하고 설치를 하기 위해서다. 즉 package.json 파일이 변경이 안된 경우에는 따로 npm install을 진행 안한다.

COPY . .

CMD ["npm", run", "start"]
  • 로컬 소스폴더를 해당 도커 이미지내 /app 폴더로 복사

  • 마지막으로 npm run start 를 통해 서버 실행

그럼 도커파일을 빌드를 해보자.

> docker build .

unable to prepare context: unable to evaluate symlinks in Dockerfile path: lstat ~/docker/frontend/Dockerfile: no such file or directory

하지만 오류가 나올거로 예상된다. 이유는 빌드파일을 개발용으로 해서 직접 지정을 해서 빌드를 해줘야 한다. 일반적으로 docker build . 를 하는 경우 해당 위치에서 Dockerfile 를 찾는다.

다시 파일이름을 지정해서 빌드해보자.

  • Docerfile.dev 파일을 현재 위치에서 찾는다.

> docker build -f Dockerfile.dev .

Sending build context to Docker daemon 138.9MB
Step 1/6 : FROM node:alpine
---> 7ca2f9cb5536
Step 2/6 : WORKDIR '/app'
---> Using cache
---> 0fbd7e754211
Step 3/6 : COPY package.json .
---> Using cache
---> 0f93f7b66b3b
Step 4/6 : RUN npm install
---> Using cache
---> 5248cc4d46df
Step 5/6 : COPY . .
---> daa3db343355
Step 6/6 : CMD ["npm", run", "start"]
---> Running in c0e8e7a66d50
Removing intermediate container c0e8e7a66d50
---> b87605719148
Successfully built b87605719148

정상적으로 만들어 졌지만 처음 만들어질때 용량이 큰걸 확인할 수가 있다.

Sending build context to Docker daemon  138.9MB

로컬내 node_modules 폴더가 같이 포함된 경우로 보여진다. 이제 삭제하고 다시 실행해보자.

> docker build -f Dockerfile.dev .

Sending build context to Docker daemon 678.9kB
Step 1/6 : FROM node:alpine
---> 7ca2f9cb5536
Step 2/6 : WORKDIR '/app'
---> Using cache
---> 0fbd7e754211
Step 3/6 : COPY package.json .
---> Using cache
---> 0f93f7b66b3b
Step 4/6 : RUN npm install
---> Using cache
---> 5248cc4d46df
Step 5/6 : COPY . .
---> 226600115590
Step 6/6 : CMD ["npm", run", "start"]
---> Running in 948b682ec173
Removing intermediate container 948b682ec173
---> 2211f808c9dc
Successfully built 2211f808c9dc

삭제하고 다시 실행해보면 용량이 줄어든 걸 볼 수 있다.

이제 서버를 실행

  • 방금 빌드 한 이미지 아이디를 이용한다.

> docker run 2211f808c9dc


> frontend@0.1.0 start /app
> react-scripts start

Starting the development server...

정상적으로 서버가 올라가는 걸 볼수 있다. 하지만 로컬에서 접속시 안될것이다. 이유는 포트를 도커쪽과 바인딩을 해줘야 하기 때문이다.

그럼 다시 바인딩을 해보자.

> docker run -p 3000:3000 2211f808c9dc

> frontend@0.1.0 start /app
> react-scripts start

Starting the development server...
....

정상적으로 올라갔다. 브라우저를 통해서 접속시 제대로 올라가는 걸 볼수 있다.

로컬 서버 소스를 변경시??

그럼 만약 로컬 소스를 변경시 자동으로 도커내 이미지 소스와 연동되서 변경이 되는 걸까?

테스트를 위해서 src/app.js를 변경 해보자.

...
Hello World
...

그런데 아직 도커 웹 서버는 변경이 안되는 것 같다. 이유는 도커를 빌드시 해당 소스에 대한 스냅샷을 만들어서 이미지를 올리기 때문이다.

그럼 변경때마다 올려줘서 새로운 이미지로 만들어 줘야 하는 걸까?

윈도우 기반 앱 개발시 변경되는 시기가 늦는 경우가 있다. 이럴 경우 .env 파일을 루트에 만들고 CHOKIDAR_USEPOLLING=true 추가를 하자. 그 이유에 대해서는 링크 를 참고하자.

지금까지 구조에서는 로컬 폴더내 소스들이 그대로 스냅샷을 만들어서 복사해서 사용하는 구조이다. 하지만 로컬 서버내 소스가 그대로 변경사항을 적용하기 위해서는 reference 을 만들어서 소스 폴더들을(app) 매핑하는게 좋아보인다.

소스 폴더를 레퍼런스로 매핑하자

  • 다시 이미지를 빌드

  • docker run with volume

$ docker build -f Dockerfile.dev .

Sending build context to Docker daemon 679.9kB
Step 1/6 : FROM node:alpine
---> 7ca2f9cb5536
Step 2/6 : WORKDIR '/app'
---> Using cache
---> 0fbd7e754211
Step 3/6 : COPY package.json .
---> Using cache
---> 0f93f7b66b3b
Step 4/6 : RUN npm install
---> Using cache
---> 5248cc4d46df
Step 5/6 : COPY . .
---> d88f67529cd4
Step 6/6 : CMD ["npm", "run", "start"]
---> Running in 12cbb9466cce
Removing intermediate container 12cbb9466cce
---> 9df085e7b11b
Successfully built 9df085e7b11b
  • docker run with volume

$ docker run -p 3000:3000 -v $(pwd):/app 9df085e7b11b

> frontend@0.1.0 start /app
> react-scripts start

sh: react-scripts: not found
npm ERR! file sh
npm ERR! code ELIFECYCLE
npm ERR! errno ENOENT
npm ERR! syscall spawn
npm ERR! frontend@0.1.0 start: `react-scripts start`
npm ERR! spawn ENOENT
npm ERR!
npm ERR! Failed at the frontend@0.1.0 start script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm WARN Local package.json exists, but node_modules missing, did you mean to install?

npm ERR! A complete log of this run can be found in:
npm ERR!     /root/.npm/_logs/2018-10-21T20_59_02_912Z-debug.log

에러가 발생했다. 디펜시즈들을 찾을수가 없다고 한다. 이유는 app 폴더를 레퍼런스를 만들었지만 처음에 node_modules 폴더를 삭제를 했었다. 그래서 그 안에 레퍼런스를 찾을수가 없다.

** 즉 dockerfile에서 COPY가 진행될때 기존에 만든 유저 폴더를 복사하기 때문에 node_modules 가 없어지기 때문이다.

node_moduels 레퍼런스를 다시 설정해주자.

$ docker run -p 3000:3000 -v /app/node_modules -v $(pwd):/app 9df085e7b11b

> frontend@0.1.0 start /app
> react-scripts start

Starting the development server...

Compiled successfully!

You can now view frontend in the browser.

Local:           http://localhost:3000/
On Your Network: http://172.17.0.2:3000/

Note that the development build is not optimized.
To create a production build, use yarn build.

정상적으로 실행되는 걸 볼수 있다. 정리하자면

docker run -p 3000:3000 -v /app/node_modules -v $(pwd):/app 9df085e7b11b

을 통해서 /app/node_modules$(pwd):/app 을 통해서 레퍼런스를 만들고 바인딩을 해주고 있다.

pwd 는 현재 내 위치를 뜻한다.

그럼 src/app.js 소스를 다시 수정해보자.

...
Good React
...

정상적으로 리로딩 되서 화면에 나오는 걸 볼수 있다.

docker run ~~ 뭐시기... 아 너무 길어

  • docker run -p 3000:3000 -v /app/node_modules -v $(pwd):/app 9df085e7b11b !!

  • 명령어가 너무 길다. 이걸 편하게 쓸수 있지 않을까?

  • docker-compose.yml 을 이용할 수 있다.

  • docker-compose.yml

version: '3'
services:
web:
  build: .
  ports:
    - "3000:3000"
  volumes:
    - /app/node_modules
    - .:/app

docker-compose up or docker-compose up --build

  • 두 명령어의 차이점은 새로 빌드를 하는 경우 --build를 통해서 올릴수 있다.

$ docker-compose up --build
Building web
ERROR: Cannot locate specified Dockerfile: Dockerfile

그런데 실행해보면 빌드 오류가 나올것이다. 이유는 처음에 빌드시 설명했듯이 Dockerfile을 찾을수가 없기 때문이다. 참고로 현재 폴더에는 Dockerfile.dev로 되어 있다.

다시 해당 도커 파일을 지정해보자.

version: '3'
services:
web:
  build:
    context: .
    dockerfile: Dockerfile.dev
  ports:
    - "3000:3000"
  volumes:
    - /app/node_modules
    - .:/app  

    build:
    context: .
    dockerfile: Dockerfile.dev

해당 폴더내 Dockerfile.dev 를 지정해줄 수 있다.

그럼 다시 up을 해보자.

$ docker-compose up
Recreating frontend_web_1 ... done
Attaching to frontend_web_1
web_1 |
web_1 | > frontend@0.1.0 start /app
web_1 | > react-scripts start
web_1 |
web_1 | Starting the development server...
web_1 |
web_1 | Compiled successfully!
web_1 |
web_1 | You can now view frontend in the browser.
web_1 |
web_1 |   Local:           http://localhost:3000/
web_1 |   On Your Network: http://172.18.0.2:3000/
web_1 |
web_1 | Note that the development build is not optimized.
web_1 | To create a production build, use yarn build.
web_1 |

정상적으로 웹에서 테스팅도 해보면 실행되는 걸 확인할 수 있다. 그리고 /src/app.js 소스도 수정해보자.

...
Good Choice React
...

테스트를 설정해보자.

우선 위에 과정들에서 테스팅을 한다고 했을 경우

  • 우선 docker build를 다시 하자.


docker build -f Dockerfile.dev .
  • docker run test를 진행

$ docker run ac00898b924c npm run test


> frontend@0.1.0 test /app
> react-scripts test

PASS src/App.test.js
✓ renders without crashing (32ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:       2.138s
Ran all test suites.


일단 테스팅이 되긴 하는데...명령어를 더이상 입력못하는 상태가 되었다. <br/> 그럼 -it 옵션을 붙여서 직접 sh로 들어가서 실행해보자.

docker run -it ac00898b924c npm run test

PASS src/App.test.js
✓ renders without crashing (5ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:       0.095s, estimated 1s
Ran all test suites.

Watch Usage
› Press f to run only failed tests.
› Press o to only run tests related to changed files.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.

명령어도 직접 입력도 되고 정상적으로 테스팅이 되는 걸 볼수 있다.

하지만 테스팅 소스 수정하면??

로컬에 있는 테스팅 소스를 수정시 정상적으로 업데이트가 안되는 걸 볼수 있다.

테스팅을 2개 한다고 가정해보자. 기존에는 한개였음

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App />, div);
 ReactDOM.unmountComponentAtNode(div);
});

it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App />, div);
 ReactDOM.unmountComponentAtNode(div);
});

테스팅을 1개에서 2개로 수정한 후 다시 docker 에서 실행해보자.

 PASS  src/App.test.js
  renders without crashing (26ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.643s
Ran all test suites.

Watch Usage
Press f to run only failed tests.
Press o to only run tests related to changed files.
Press p to filter by a filename regex pattern.
Press t to filter by a test name regex pattern.
Press q to quit watch mode.
Press Enter to trigger a test run.

여전히 1개만 테스팅이 되는 걸 볼수 있다.

해결 방안 중 하나는 docker run을 한 상태에서 다른 콘솔에서 다시 테스팅을 진행하는 방법이다.

$ docker compose-up
....
$ docker ps

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                    NAMES
2c673669e1d6        frontend_web        "npm run start"     20 minutes ago      Up 20 minutes       0.0.0.0:3000->3000/tcp   frontend_web_1

$ docker exec -it 2c673669e1d6 npm run test
...

exec 명령어를 통해서 실행되어 있는 서버에 라이브로 붙어서 테스팅을 할 수 있다.

하지만 이건 좋은 프렉티스가 아니다.

docker-compose 에 새로운 명령어를 추가하자.

  • docker-compose.yml

  • tests 콘테이너를 추가

version: '3'
services:
web:
  build:
    context: .
    dockerfile: Dockerfile.dev
  ports:
    - "3000:3000"
  volumes:
    - /app/node_modules
    - .:/app  
tests:
  build:
    context: .
    dockerfile: Dockerfile.dev
  volumes:  
    - /app/node_modules
    - .:/app  
  command: ["npm", "run", "test"]  

그럼 실행해보자

$ docker-compose up --build

Building web
Step 1/6 : FROM node:alpine
 ---> 7ca2f9cb5536
Step 2/6 : WORKDIR '/app'
 ---> Using cache
 ---> 0fbd7e754211
Step 3/6 : COPY package.json .
 ---> Using cache
 ---> 5df741ed1932
Step 4/6 : RUN npm install
 ---> Using cache
 ---> 667dcc1e7cff
Step 5/6 : COPY . .
 ---> 4d785a4cbb04
Step 6/6 : CMD ["npm", "run", "start"]
 ---> Running in c8a14cb7bbf7
Removing intermediate container c8a14cb7bbf7
 ---> 7e86b10b989a
Successfully built 7e86b10b989a
Successfully tagged frontend_web:latest
Building tests
Step 1/6 : FROM node:alpine
 ---> 7ca2f9cb5536
Step 2/6 : WORKDIR '/app'
 ---> Using cache
 ---> 0fbd7e754211
Step 3/6 : COPY package.json .
 ---> Using cache
 ---> 5df741ed1932
Step 4/6 : RUN npm install
 ---> Using cache
 ---> 667dcc1e7cff
Step 5/6 : COPY . .
 ---> Using cache
 ---> 4d785a4cbb04
Step 6/6 : CMD ["npm", "run", "start"]
 ---> Using cache
 ---> 7e86b10b989a
Successfully built 7e86b10b989a
Successfully tagged frontend_tests:latest
Recreating frontend_tests_1 ... done
Recreating frontend_web_1   ... done
Attaching to frontend_tests_1, frontend_web_1
tests_1  | 
tests_1  | > frontend@0.1.0 test /app
tests_1  | > react-scripts test
tests_1  | 
web_1    | 
web_1    | > frontend@0.1.0 start /app
web_1    | > react-scripts start
web_1    | 
web_1    | Starting the development server...
web_1    | 
tests_1  | PASS src/App.test.js
tests_1  |   ✓ renders without crashing (39ms)
tests_1  | 
tests_1  | Test Suites: 1 passed, 1 total
tests_1  | Tests:       1 passed, 1 total
tests_1  | Snapshots:   0 total
tests_1  | Time:        3.597s
tests_1  | Ran all test suites.
tests_1  | 
web_1    | Compiled successfully!
web_1    | 
web_1    | You can now view frontend in the browser.
web_1    | 
web_1    |   Local:            http://localhost:3000/
web_1    |   On Your Network:  http://172.18.0.2:3000/
web_1    | 
web_1    | Note that the development build is not optimized.
web_1    | To create a production build, use yarn build.
web_1    | 

그런 후에 test에 하나의 테스트를 더 추가를 해보자.

  • App.test.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App />, div);
 ReactDOM.unmountComponentAtNode(div);
});

it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App />, div);
 ReactDOM.unmountComponentAtNode(div);
});

it('renders without crashing', () => {
 const div = document.createElement('div');
 ReactDOM.render(<App />, div);
 ReactDOM.unmountComponentAtNode(div);
});

그럼 도커내에서 자동적으로 테스팅이 추가되서 실행되는 걸 볼수 있다.

tests_1  | PASS src/App.test.js
tests_1  |   renders without crashing (29ms)
tests_1  |   renders without crashing (3ms)
tests_1  |   renders without crashing (3ms)
tests_1  |
tests_1  | Test Suites: 1 passed, 1 total
tests_1  | Tests:       3 passed, 3 total
tests_1  | Snapshots:   0 total
tests_1  | Time:        2.647s, estimated 3s
tests_1  | Ran all test suites.
tests_1  |

컨테이너가 2개가 올라간걸 볼수 있다.

다른 콘솔을 열어서 실행해보자.

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                    NAMES
0ba2a75bd96a        frontend_web        "npm run start"     5 minutes ago       Up 5 minutes        0.0.0.0:3000->3000/tcp   frontend_web_1
2a40cff30f4f        frontend_tests      "npm run test"      5 minutes ago       Up 5 minutes                                 frontend_tests_1

Nginx 웹서버 추가 등록

  • 이제 프로덕션 하자.

  • Dockerfile 파일내

  1. React 프로젝트를 빌드

  2. npm run build

  3. 최종 나온 빌드 소스를 nginx html 폴더 복사

  4. 그리고 빌드 된걸 run을 해서 서버를 실행하자.

  • Dockerfile 파일 생성

FROM node:alpine as builder
WORKDIR '/app'
COPY package.json .
RUN npm install
COPY . .
RUN npm run build

FROM nginx
COPY --from=builder /app/build /usr/share/nginx/html

$ docker build .


Sending build context to Docker daemon   683kB
Step 1/8 : FROM node:alpine as builder
---> 7ca2f9cb5536
Step 2/8 : WORKDIR '/app'
---> Using cache
---> 0fbd7e754211
Step 3/8 : COPY package.json .
---> Using cache
---> 0f93f7b66b3b
Step 4/8 : RUN npm install
---> Using cache
---> 5248cc4d46df
Step 5/8 : COPY . .
---> 8a445e0ba651
Step 6/8 : RUN npm run build
---> Running in 56d7fc2d10e8

> frontend@0.1.0 build /app
> react-scripts build

Creating an optimized production build...
Compiled successfully.

File sizes after gzip:

32.4 KB build/static/js/1.6105a37c.chunk.js
763 B   build/static/js/runtime~main.229c360f.js
689 B   build/static/js/main.7d28dc35.chunk.js
510 B   build/static/css/main.6e4ad141.chunk.css

The project was built assuming it is hosted at the server root.
You can control this with the homepage field in your package.json.
For example, add this to build it for GitHub Pages:

"homepage" : "http://myname.github.io/myapp",

The build folder is ready to be deployed.
You may serve it with a static server:

yarn global add serve
serve -s build

Find out more about deployment here:

http://bit.ly/CRA-deploy

Removing intermediate container 56d7fc2d10e8
---> a5886e8cd892
Step 7/8 : FROM nginx
---> dbfc48660aeb
Step 8/8 : COPY --from=builder /app/build /usr/share/nginx/html
---> b85d40ae190b
Successfully built b85d40ae190b

정상적으로 빌드 된 걸 볼수 있다.

이제 마지막으로 실행해보자.

docker run -p 8080:80 b85d40ae190b

http://localhost:8080/ 으로 실행하면 nginx 서버로 접속되는 걸 볼수 있다.

이상으로 도커 & 쿠버네이트 3주차 스터디 한 내용이었다.



배달앱 클론 3주차 내용

메인화면에서 ListActivity 로 이동

메인화면에 RecyclerView 를 등록 한 후에 GridLayoutManager 를 등록한 후 일것이다.

그럼 Adapter 에 클릭 이벤트를 등록해서 ListAcitivity 로 이동 처리 한다.

fun <T : RecyclerView.ViewHolder> T.listen(event: (position: Int, type: Int) -> Unit): T {
   itemView.setOnClickListener {
       event.invoke(adapterPosition, itemViewType)
  }
   return this
}
  • MainRecyclerViewAdapter

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.main_recyclerview_item, parent, false)
return ViewHolder(view).listen{ pos, type ->
clickListener(items[pos])
}
}

ListActivity 생성 및 ViewPager 등록

  • ListActivity 생성

  • Main 으로 부터 category 데이터를 parcelable 형태로 데이터 전달 받는다.

category = intent.getParcelableExtra("item")
  • ViewPager 등록

val pagerAdapter = TabPageAdapter(supportFragmentManager, items)
       pager.adapter = pagerAdapter
       pager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabLayout))

tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener{
   override fun onTabReselected(tab: TabLayout.Tab?) {

  }

   override fun onTabUnselected(tab: TabLayout.Tab?) {

  }

   override fun onTabSelected(tab: TabLayout.Tab) {
       pager.currentItem = tab.position
  }
})

pager.currentItem = items.indexOf(category?.title)
  • ListRecyclerViewAdapter 생성해서 바인딩 해준다.

class ListRecylerViewAdapter(
   private var items: List<Store>,
   private val context: Context,
   private val clickListener : (item: Store) -> Unit
) : RecyclerView.Adapter<ListRecylerViewAdapter.ViewHolder>() {

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
       val view = LayoutInflater.from(context).inflate(R.layout.list_recyclerview_item, parent, false)
       return ViewHolder(view).listen{ pos, type ->
           clickListener(items[pos])
      }
  }

   override fun getItemCount(): Int = items.size

   override fun onBindViewHolder(holder: ViewHolder, position: Int) {
       holder.title.text = items[position].name
       val requestOptions = RequestOptions().apply{
           this.placeholder(R.drawable.place_hoder_icon)
           this.error(R.drawable.no_image)
           this.circleCrop()
      }
       Glide.with(holder.itemView)
          .setDefaultRequestOptions(requestOptions)
          .load(items[position].thumbnail)
          .into(holder.thumbnail)
  }

   class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
       val title = view.list_title
       val thumbnail = view.list_iv
  }

   fun updateItem(items : List<Store>) {
       this.items = items
       notifyDataSetChanged()
  }

}
  • Category 데이터 클래스 등록

  • Parcelable 을 상속받아서 전달할 수 있게끔 해준다.

data class Category (
   val no: Int,
   val resId: Int,
   val background: Int = R.drawable.bdt_btn_white,
   val title: String = "",
   val type: String = ""
) : Parcelable {

   constructor(parcel: Parcel) : this(
           parcel.readInt(),
           parcel.readInt(),
           parcel.readInt(),
           parcel.readString(),
           parcel.readString()) {
  }

   override fun writeToParcel(parcel: Parcel, flags: Int) {
       parcel.writeInt(no)
       parcel.writeInt(resId)
       parcel.writeInt(background)
       parcel.writeString(title)
       parcel.writeString(type)
  }

   override fun describeContents(): Int {
       return 0
  }

   companion object CREATOR : Parcelable.Creator<Category> {
       override fun createFromParcel(parcel: Parcel): Category {
           return Category(parcel)
      }

       override fun newArray(size: Int): Array<Category?> {
           return arrayOfNulls(size)
      }
  }

}
  • 액션바에 뒤로가기 아이콘 및 타이틀 구성

supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
supportActionBar?.elevation = 0f
  • 플로팅 액션 버튼 등록

  • 탭 및 viewPager 등록

private fun initTab() {

   items.forEach {
       tabLayout.addTab(tabLayout.newTab().setText(it))
  }
}
private fun initViewPager() {
       val pagerAdapter = TabPageAdapter(supportFragmentManager, items)
       pager.adapter = pagerAdapter
       pager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabLayout))

       tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener{
           override fun onTabReselected(tab: TabLayout.Tab?) {

          }

           override fun onTabUnselected(tab: TabLayout.Tab?) {

          }

           override fun onTabSelected(tab: TabLayout.Tab) {
               pager.currentItem = tab.position
          }
      })

       pager.currentItem = items.indexOf(category?.title)
  }
  • ListActivity 전체 소스

class ListActivity : AppCompatActivity() {

   var category : Category? = null

   lateinit var items : Array<String>

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_list)

       supportActionBar?.setDisplayHomeAsUpEnabled(true)
       supportActionBar?.setDisplayShowHomeEnabled(true)
       supportActionBar?.elevation = 0f

       category = intent.getParcelableExtra("item")
       title = category?.title

       fab.setOnClickListener {            
           startActivity(Intent(this, RegisterActivity::class.java))
      }

       items = resources.getStringArray(R.array.menus)

       initTab()
       initViewPager()

  }

   private fun initTab() {

       items.forEach {
           tabLayout.addTab(tabLayout.newTab().setText(it))
      }
  }

   private fun initViewPager() {
       val pagerAdapter = TabPageAdapter(supportFragmentManager, items)
       pager.adapter = pagerAdapter
       pager.addOnPageChangeListener(TabLayout.TabLayoutOnPageChangeListener(tabLayout))

       tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener{
           override fun onTabReselected(tab: TabLayout.Tab?) {

          }

           override fun onTabUnselected(tab: TabLayout.Tab?) {

          }

           override fun onTabSelected(tab: TabLayout.Tab) {
               pager.currentItem = tab.position
          }
      })

       pager.currentItem = items.indexOf(category?.title)
  }

   override fun onOptionsItemSelected(item: MenuItem?): Boolean {
       if(item?.itemId == android.R.id.home)
           finish()
       return super.onOptionsItemSelected(item)
  }
}

ListFragment, ListFragContract, ListFragPresente 생성

  • newInstance 는 Fragment 생성시 static 으로 데이터를 생성하는 방법중 하나

      companion object {
         private const val ARG_PARAM = "type"
 
         fun newInstance(type : String) : ListFrag {
             val listFrag = ListFrag()
             val args = Bundle()
             args.putString(ARG_PARAM, type)
             listFrag.arguments = args
             return listFrag
        }
    }
  • Fragment 가 열리고 firestore 로 부터 카테고리에 맞는 리스트를 가져오기 위해서 presenter 에 getStories 를 호출한다.

    arguments
       ?.getString(ARG_PARAM)
       ?.let{
           mPresenter.getStores(it)
      }
  • ListFrag 전체 소스

    class ListFrag : BaseMvpFragment<ListFragContract.View, ListFragContract.Presenter>(), ListFragContract.View {

       private var items : ArrayList<Store> = ArrayList()

       override var mPresenter: ListFragContract.Presenter = ListFragPresenter()

       lateinit var act : Activity

       private lateinit var listRecylerViewAdapter : ListRecylerViewAdapter

       companion object {
           private const val ARG_PARAM = "type"

           fun newInstance(type : String) : ListFrag {
               val listFrag = ListFrag()
               val args = Bundle()
               args.putString(ARG_PARAM, type)
               listFrag.arguments = args
               return listFrag
          }
      }

       override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
           return inflater.inflate(R.layout.frag_list, container, false)
      }

       override fun onActivityCreated(savedInstanceState: Bundle?) {
           super.onActivityCreated(savedInstanceState)
           act = activity as Activity
           initRecyclerView()

           arguments
               ?.getString(ARG_PARAM)
               ?.let{
                   mPresenter.getStores(it)
              }
      }

       private fun initRecyclerView() {
           listRecyclerView.layoutManager = LinearLayoutManager(context)
           listRecylerViewAdapter = ListRecylerViewAdapter(items, act, ::handleItem)
           listRecyclerView.adapter = listRecylerViewAdapter
           val itemDecoration = ItemOffsetDecoration(act, R.dimen.list_item_offset)
           listRecyclerView.addItemDecoration(itemDecoration)
           listRecyclerView.setEmptyView(empty_view)
      }

       fun handleItem(item: Store) {

      }

       override fun updateList(items: MutableList<Store>) {
           listRecylerViewAdapter.updateItem(items)
      }
    }

RegisterActivity 생성

  • Register 화면에서 사진 및 내용을 등록하는게 목표

  • RegisterActivity 생성시 Contract, Presetenter 도 같이 생성

    class RegisterActivity : BaseMvpActivity<RegisterContract.View, RegisterContract.Presenter>(), RegisterContract.View{
  • Presenter 생성

    override var mPresenter: RegisterContract.Presenter = RegisterPresenter()

사진 및 데이터 FirestoreFirestorage 에 등록 흐름

  1. 사진 등록 버튼 이벤트 등록

  2. 피커 열기 (카메라, 갤러리) 후 완료후 이미지 뷰에 업데이트

  3. RxImagePicker 이용해서 이미지 Uri 획득

  4. 제목 입력 후 등록 버튼 클릭

  5. 클릭과 동시 ProgressBar를 보여주기

  6. Presenter에 register 함수 실행

  7. ImageUpload 스트림 과 register 스트림을 조합

  8. Repository에서 FirebaseRepositoy을 이용해서 리턴

  9. FirebaseRepository 에서 RxFirestoreage 와 RxFirestore 를 이용해서 등록

  10. Presenter에서 정상 입력 콜백 받아서 UI로 업데이트

  11. Progressbar 종료 한 후 업데이트

  12. 등록 완료 메세지 보여주고 다시 리스트 화면으로 이동 하기

사진 등록 버튼 이벤트 등록

    btnRegister.setOnClickListener {
       registerProc()
  }

   btnGallery.setOnClickListener {
       openImagePicker(Sources.GALLERY)
  }

   btnCamera.setOnClickListener {
       openImagePicker(Sources.CAMERA)
  }

피커 열기 (카메라, 갤러리) 후 완료후 이미지 뷰에 업데이트

  • RxImagePicker 이용해서 이미지 Uri 획득

  • 이미지 피커를 열어서 카메라 또는 갤러리에서 이미지를 선택하게끔 한다.

  • RxImagePicker 를 이용해서 최종 선택된 Uri 를 가져온다.

  • Glide 은 서버, 로컬이미지를 안드로이드에 바인딩 하기 위한 라이버러리 중 하나이다. (추천)

fun openImagePicker(source: Sources) {
   val disposable = RxImagePicker.with(fragmentManager).requestImage(source)
      .subscribeBy(
           onNext = {
               this.uri = it
               val options = RequestOptions()
               options.circleCrop()
               Glide.with(this)
                  .load(it)
                  .apply(options)
                  .into(thumbnail)
          }
      )

   compositeDisposable.add(disposable)
}
  • compositeDisposable 는 disposable 을 정리하기 위한 disposeBag 이라고 생각하면 된다. 즉 subscribe 한 후에 구독한 자원을 클리어 하기 위해서 사용된다.

compositeDisposable.clear() 

제목 입력 후 등록 버튼 클릭

  • 클릭과 동시 ProgressBar를 보여주기

    fun registerProc() {
       btnRegister.visibility = View.GONE
       registerPogressBar.visibility = View.VISIBLE
       val store = Store(
           name=tv_title.text.toString(),
           categoryName = spinner.selectedItem.toString()
      )
       Timber.d("$store")
       mPresenter.register(uri = this.uri, store = store)
  }

Presenter register 함수 실행

  • ImageUpload 스트림 과 register 스트림을 조합

  • ImageUpload Stream -> Firebase storeage

        val imageObservable : Maybe<Uri>? = uri?.let{
           repository.uploadImage(uri)
      }
  • Register Stream -> Firestore

        val registerObservable = imageObservable
       ?.map {
           store.apply {
               this.thumbnail = it.toString()
          }
      }
       ?.flatMapCompletable(::registerProc)
       ?:registerProc(store)
  • regiser() 전체 소스

    override fun register(uri : Uri?, store: Store) {
       store.apply {
           id = getUUID()
      }

       val imageObservable : Maybe<Uri>? = uri?.let{
           repository.uploadImage(uri)
      }

       val registerObservable = imageObservable
           ?.map {
               store.apply {
                   this.thumbnail = it.toString()
              }
          }
           ?.flatMapCompletable(::registerProc)
           ?:registerProc(store)

       val disposable = registerObservable
          .subscribeBy(
               onComplete = {
                   mView?.registerDone()
              }
          )

       compositeDisposable.add(disposable)
  }

   fun registerProc(store : Store): Completable {
       return repository.register(store)
  }

Repository에서 FirebaseRepositoy을 이용해서 리턴

  • Image 등록

      fun uploadImage(uri: Uri): Maybe<Uri>? {
         return FirebaseRepository.uploadImage(uri)
    }
  • Firestore 에 이미지 링크와 데이터 등록

      fun register(store: Store) : Completable {
         return FirebaseRepository.register(store)
    }
  1. FirebaseRepository 에서 RxFirestoreage 와 RxFirestore 를 이용해서 등록

  • FirebaseRepository 에서 uploadImage 함수 구현

    override fun uploadImage(uri: Uri): Maybe<Uri>? {
       val ref = firebaseStorage.reference.child(getUUID())

       return RxFirebaseStorage.putFile(ref, uri)
          .flatMapMaybe {
               RxFirebaseStorage.getDownloadUrl(ref)
          }
    }
  • Firebase firestore 에 데이터 입력

      override fun register(store: Store): Completable {
         val document = firestoreApp.collection(store.categoryName ?: "").document( store.id ?: throw Exception("Empty ID") )
         return RxFirestore.setDocument(document, store)
    }
  1. Presenter에서 정상 입력 콜백 받아서 UI로 업데이트

  • 정상적으로 받아서 UI 에 등록 완료 푸시

    ...
   mView?.registerDone()
  ...    
  1. Progressbar 종료 한 후 업데이트

    override fun registerDone() {
      btnRegister.visibility = View.VISIBLE
      registerPogressBar.visibility = View.GONE
      Toast.makeText(this, "등록완료", Toast.LENGTH_SHORT).show()
      finish()
  }

주의 해야 할 부분

  • Source 인터페이스에서 네트워크와 파베 리포지토리에 등록할 공통 함수 구현

interface Source {

   fun getConvertedAddr(lat : Double, lng : Double ) : Observable<Address>

   fun register(store: Store) : Completable

   fun uploadImage(uri: Uri) : Maybe<Uri>?

   fun getStores(type: String) : Maybe<MutableList<Store>>
}

rxfirestore 및 rxfirebaseStorage 디펜시즈 추가

    //RxFirebase
  implementation 'com.github.FrangSierra:RxFirebase:1.5.0'
  implementation 'com.oakwoodsc.rxfirestore:rxfirestore:1.1.0'
  implementation 'com.oakwoodsc.rxfirestore:rxfirestorekt:1.1.0'
  • FirebaseRepository by Source

    • RxFirestore 이용해서 등록 후 Completable 형태로 리턴받음

    • RxFirebaseStorage 에 파일을 등록 하면 Maybe 로 리턴 받음

      • Maybehttp://reactivex.io/RxJava/javadoc/io/reactivex/Maybe.html 에서 자세히 볼수 있다.

      • 값이 나올수도 있거나 null 이 올수 있다는 뜻이다.

      • SingleMaybe 과 차이점은 Singlenull 값을 받을 경우 에러로 리턴된다.

      • flatMapMaybe 은 옵저버블을 리턴시 Maybe 로 리턴이 되는 경우이다.

      object FirebaseRepository : Source {
         val firestoreApp by lazy {
             FirebaseFirestore.getInstance()
        }

         val firebaseStorage by lazy {
             FirebaseStorage.getInstance("버킷 주소")
        }

         override fun register(store: Store): Completable {
             val document = firestoreApp.collection(store.categoryName ?: "").document( store.id ?: throw Exception("Empty ID") )
             return RxFirestore.setDocument(document, store)
        }

         override fun uploadImage(uri: Uri): Maybe<Uri>? {
             val ref = firebaseStorage.reference.child(getUUID())

             return RxFirebaseStorage.putFile(ref, uri)
                .flatMapMaybe {
                     RxFirebaseStorage.getDownloadUrl(ref)
                }
        }

         override fun getStores(type: String): Maybe<MutableList<Store>> {
             val collectionRef = firestoreApp.collection(type)
             return RxFirestore.getCollection(collectionRef, Store::class.java)
                .doOnError {
                     Timber.e(it)
                }
        }
      }

Firebase storage에 버킷 주소 꼭 확인 후 맞는 걸 넣어줘야 함

업체 등록 후 List 화면에서 정상적인 업체가 나오는지 확인

  • 이미 등록한 내용에 대해서 잘 나오는 지 firestore 콘솔에 가서 직접 확인 필요

    1. Presenter에서 getStores 를 호출

          override fun getStores(type: String) {
             val disposable = repository.getStores(type)
                .subscribeBy(
                     onSuccess = {
                         Timber.d("list : $it")
                         mView?.updateList(it)
                    }
                )

             compositeDisposable.add(disposable)
        }
    2. RepositoryImpl 에서 getStore 호출해서 firebase 에서 호출

          fun getStores(type: String) : Maybe<MutableList<Store>>{
             return FirebaseRepository.getStores(type)
        }
    3. FirebaseRepository 에서 RxFirestore 이용해서 데이터를 가져와서 리턴함

      override fun getStores(type: String): Maybe<MutableList<Store>> {
         val collectionRef = firestoreApp.collection(type)
         return RxFirestore.getCollection(collectionRef, Store::class.java)
      }
    4. Presenter 에서 UI 단으로 업데이트 처리

      ...
         mView?.updateList(it)
      ...
    5. ListFrag에서 adapter에서 리스트 처리

          override fun updateList(items: MutableList<Store>) {
             listRecylerViewAdapter.updateItem(items)
        }
    6. Adapter에서 notifyDataSetChanged() (갱신) 호출

          fun updateItem(items : List<Store>) {
             this.items = items
             notifyDataSetChanged()
        }

이상으로 3주차 배달앱 클론 스터디 내용입니다.

다음시간에는(마지막시간) 복습 후에 마지막 메인쪽 왼쪽 서랍 메뉴 커스텀과 삭제 기능 및 디테일을 좀 더 다를 예정입니다.



보통 이런 오류는 lazy 하게 네트워크 상태가 늦어지거나 할 경우 이미 위젯은 dispose가 된 상태인데 setState 를 호출 하는 경우로 보인다. 


해결법은 간단하게 onDispose 함수에서 변수 하나 선언해서 true / false 로 해주면 된다. 


라이프사이클에 대해서 궁금하면 여기 로 가서 한번 보고 오자.


우선 이렇게 변수를 지정 후에

@override
void dispose() {
super.dispose();
isDisposed = true;
}


변경하는 부분에서 

if(!isDisposed) {
setState(() {

....
});
}

이런식으로 하면 된다. 


팁으로 시작시 setState 를 하는 경우에는 this.mounted 로 체크가 가능하다.


이상으로 늦은 setState 오류에 대해서 살펴보았다. 



Android 사용시 자주 사용되는 게 같은 Task 내에서 onActivityForResult 를 사용하는 편이죠. 

물론 안드로이드내에서는 호출하는 쪽에서는 startActivityForResult 를 하고 다음 엑티비티에서 할일 하고 값을 다시 보낼때 onActivityResult 를 통해서 값을 받습니다.


관련 글은 http://liveonthekeyboard.tistory.com/150 보시면 됩니다. 


Flutter 내에서는 라우터를 이용을 할텐데요 그럴경우 주고 받는 걸 어떻게 하는 지 볼게요. 


우선 A -> B -> A 라고 가정하면

A 에서 라우팅을 

Map results =  await Navigator.of(context).push(new MaterialPageRoute<dynamic>(
    builder: (BuildContext context) {
      return new SelectionPage();
    },
  ));

라고 Map 형태로 Future 함수로 받습니다. await 도 넣어야 합니다. 


그리고 B 함수에서 pop 을 할때 값을 보내줍니다. 

void _selectItem(String value, BuildContext context) {
    Navigator.of(context).pop({'selection':value});
  }

이렇게 pop 할때 value 값을 보내줍니다.

그러면 받는 A 쪽에서는 마지막으로

Future _buttonTapped() async {
  Map results =  await Navigator.of(context).push(new MaterialPageRoute<dynamic>(
    builder: (BuildContext context) {
      return new SelectionPage();
    },
  ));
 
  if (results != null && results.containsKey('selection')) {
    setState(() {
      _selection = results['selection'];
    });
  }
}

이렇게 result 로 값을 체크해서 setState 할수가 있습니다. 


이상으로 flutter에서 onActivityForResult 기능 구현에 대해서 살펴보았습니다.





Flutter로 작업시 Android Native 모듈과 통신할 일이 생길 수 있다. 

그럴경우 android 폴더를 오른쪽 클릭 후 open Android module in Android Studio 로 열면 된다. 


기존에 Flutter에 라이버러리들을 안 붙인 상태이면 정상적으로 작동이 될것으로 보인다. 

하지만 다른 라이버러리들을 사용을 하고 있는 경우 하단의 오류 같은게 나올 수 있다. 

이 부분을 해결하기 위해선 플러터 오픈 채팅방에서 고수 두부랩님이 알려주신 팁으로

gradle 폴더안에 gradle-wrapper.properties 파일을 열어서 gradle 버전을 수정해주면 된다. 

distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip

그러면 정상적으로 빌드가 되는걸 볼수 있다. 


Flutter 화이팅!!

Flutter BuildContext 알아보기

https://flutterbyexample.com/build-context-class 보고 공부한 내용입니다.

Flutter 에서 BuildContext는 위젯 트리에 있는 위젯 위치라고 보면 된다.

각각의 위젯마다 고유의 BuildContext 가 존재한다.

class _MyHomePageState extends State<MyHomePage> {
  _MyHomePageState() {
    print(context.hashCode);
    // prints 2011
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Container(),
      floatingActionButton:
          new FloatingActionButton(onPressed: () => print(context.hashCode)),
          // prints 63
    );
  }
}

위에서 보듯이 두개의 context 의 hashcode가 다르게 보인다.

그래서 of 함수를 사용시 조심해서 사용해야 한다.

of 함수는 위젯트리에서 위,아래로 옮겨다니면서 해당 위젯을 참조한다.

대표적인 예로 Theme 를 들수 있는데

@override
Widget build(context) {
  return new Text('Hello, World',
    style: new TextStyle(color: Theme.of(context).primaryColor),
  );
}

이경우 Theme 라는 위젯을 참조해서 해당 컬러를 가져오는 경우이다.

다른 예로 snackbar 위젯을 들수 있다. snackbar 표시하는 데 있어서 Scaffold 위젯 build context가 필요하다. 그럼 이렇게 하는건 가능할까?

@override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text(widget.title),
        ),
        body: new Container(),
        /// Scaffold doesn't exist in this context here
        /// because the context thats passed into 'build'
        /// refers to the Widget 'above' this one in the tree,
        /// and the Scaffold doesn't exist above this exact build method
        ///
        /// This will throw an error:
        /// 'Scaffold.of() called with a context that does not contain a Scaffold.'
        floatingActionButton: new FloatingActionButton(onPressed: () {
          Scaffold.of(context).showSnackBar(
                new SnackBar(
                  content: new Text('SnackBar'),
                ),
              );
        }));
  }

이건 동작이 안될 것이다. 이유는 Scaffold.of(context).showSnackBar 에서 context가 Scaffold 이지 않기 때문이다. 그래서 이걸 해결 하기 위해서는 Builder 함수를 통해서 context 를 전달을 할 수 있다.

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Container(),
      /// Builders let you pass context
      /// from your *current* build method
      /// Directly to children returned in this build method
      ///
      /// The 'builder' property accepts a callback
      /// which can be treated exactly as a 'build' method on any
      /// widget
      floatingActionButton: new Builder(builder: (context) {
        return new FloatingActionButton(onPressed: () {
          Scaffold.of(context).showSnackBar(
                new SnackBar(
                  backgroundColor: Colors.blue,
                  content: new Text('SnackBar'),
                ),
              );
        });
      }),
    );
  }

이상으로 BuildContext를 공부해보았다.

Flutter 에서 내위치 가져오기

모바일에서 내 위치를 가져오는 건 필수 기능 중 하나!!

관련 플러그인은 https://pub.dartlang.org/packages/geolocator#-readme-tab- 에서 확인 가능하다.

그럼 설정은?

pubspec.yaml

geolocator: '^2.0.1'

Geolocator 라는 메인 클래스를 통해서 위치를 가져 올 수 있다.

Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.high)

리턴 값은 Future 비동기로 받는다. 그래서 then이나 await 로 사용이 가능하다.

Future<Position> getCurrentUserLocation() async {
    return Geolocator()
        .getCurrentPosition(desiredAccuracy: LocationAccuracy.high)
        .then((location) {      
      return location;
    });
  }

추후 이 함수(getCurrentUserLocation) 을 FutureBuilder 와 연계해서 UI 부분에서 핸들링 가능해 보인다.

이상으로 내 위치를 가져오는 방법에 대해서 알아보았다.

Flutter 개발자 오픈 채팅방 
https://open.kakao.com/o/gsshoXJ


2주차 배달앱 강의 스터디 수업 내용

1주차 복습 내용

http://javaexpert.tistory.com/971?category=720102

2주차 클론 수업 내용

  1. Category 데이터 클래스 구현
  2. List<Category> 구현
  3. 리사이클러뷰를 그리드 레이아웃 매니저로 구현 및 데이터 연결
  4. GPS 기능 켜져있는지 체크
  5. 유저 위치 가져오기 위해 퍼미션 작업
  6. Presenter 함수 구현
  7. Address Object Data 클래스 구현
  8. 퍼미션 승인 후 트래킹 진행
  9. 좌표를 가져온 후 네이버 지오코딩 api 를 통해 자세한 동을 가져온다.
  10. Retrofit Api 구현
  11. converter-moshi 를 통해 JSON -> Address Class 로 변환
  12. RxJava Converter 구현
  13. RxJava Subscribe 를 통해 화면에 푸시
  14. 최종적으로 화면에 동이름을 뿌리도록 진행

Category 데이터 클래스 구현

  • resId 는 아이템에 들어갈 이미지 drawable 위치
  • background 는 아이템 뒤에 들어갈 백그라운드 이미지 ( drawable xml 이미지)
  • type 은 서브 페이지로 넘길때 필요한 파라미터
data class Category (
        val no: Int,
        val resId: Int,
        val background: Int = R.drawable.bdt_btn_white,
        val title: String = "",
        val type: String = ""
)

리사이클러뷰를 그리드 레이아웃 매니저로 구현

  • content_main.xml 에서 RecyclerView 를 구현
   <android.support.v7.widget.RecyclerView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/mainRecyclerView"
        android:clipToPadding="false"
        android:padding="@dimen/item_offset"
        />

List<Category> 구현해서 리스트에 데이터로 연결

  • 메인화면에 표시될 배열 미리 정의
    val items = arrayOf(
            Category(0, R.drawable.bdt_home_a_ctgr_all, title = "전체"),
            Category(1, R.drawable.bdt_home_a_ctgr_seasonal, R.drawable.bdt_btn_brown, title = "계절메뉴"),
            Category(2, R.drawable.bdt_home_a_ctgr_chicken, title = "치킨"),
            Category(3, R.drawable.bdt_home_a_ctgr_chinese, title = "중식"),
            Category(4, R.drawable.bdt_home_a_ctgr_pizza, title = "피자"),
            Category(5, R.drawable.bdt_home_a_ctgr_bossam, title = "족발,보쌈"),
            Category(6, R.drawable.bdt_home_a_ctgr_burger, title = "도시락"),
            Category(7, R.drawable.bdt_home_a_ctgr_japanese, title = "일식")
    )

리사이클러뷰를 그리드 레이아웃 매니저로 구현 및 데이터 연결

    ...
    private fun initRecyclerView() {
        mainRecyclerView.layoutManager = GridLayoutManager(getContext(), 2)
        mainRecyclerView.adapter = MainRecylerViewAdapter(items, getContext(), ::handleItem)
        val itemDecoration = ItemOffsetDecoration(getContext(), R.dimen.item_offset)
        mainRecyclerView.addItemDecoration(itemDecoration)
    }

GPS 기능 켜져있는지 체크

  • 기능이 꺼져있는 경우 강제로 GPS 설정화면으로 이동시킴
fun checkGPS() {
        val service = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        val enabled = service
                .isProviderEnabled(LocationManager.GPS_PROVIDER)
        
        if (!enabled) {
            val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
            startActivity(intent)
        }
    }

유저 위치 가져오기 위한 퍼미션 체크

    private fun checkPermission() {

        if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION)
                == PackageManager.PERMISSION_GRANTED) {
            updateMyLocation()
        } else {
            ActivityCompat.requestPermissions(this,arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION), 1);
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        if (permissions.size == 1 &&
                permissions[0] == android.Manifest.permission.ACCESS_FINE_LOCATION &&
                grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
                return
            }
        }else {
            updateMyLocation()
        }
    }

위치 가져온 후 내 위치 트래킹

  • 정상적으로 트래킹 후 Presenter로 한국 주소를 얻기 위해 좌표를 보냄
fun updateMyLocation() {
        locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        val criteria = Criteria()
        // 정확도
        criteria.setAccuracy(Criteria.NO_REQUIREMENT);
        // 전원 소비량
        criteria.setPowerRequirement(Criteria.NO_REQUIREMENT);
        // 고도, 높이 값을 얻어 올지를 결정
        criteria.setAltitudeRequired(false);
        // provider 기본 정보(방위, 방향)
        criteria.setBearingRequired(false);
        // 속도
        criteria.setSpeedRequired(false);
        // 위치 정보를 얻어 오는데 들어가는 금전적 비용
        criteria.setCostAllowed(true);

        provider = locationManager.getBestProvider(criteria, false);
        val location = locationManager.getLastKnownLocation(provider);
        if(location != null) {
            Log.d("KTH","${location.latitude},${location.longitude}")
            mPresenter.getAddr(lng = location.longitude, lat = location.latitude)
        }
    }

Presenter 구현

  • 좌표를 repository 에 다시 보내서 Observable<Address> 형태로 리턴 받음
  • compositeDisposable 는 메모리릭을 위해 구독된 disposable 을 detach 때 제거를 해준다.
override fun getAddr(lng: Double, lat: Double) {        
        val disposable = repository.convertAddr(lng, lat)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                        onNext = {                            
                            mView?.updateAddress(it.items[0].addressDetail.dongmyun)
                        },
                        onError = {
                            it.printStackTrace()
                        }
                )

        compositeDisposable.add(disposable)
    }

Adress 데이터 클래스 구현

  • 서버로 부터 결과값을 가져온 후 Address 데이터로 변환하기 위해 필요하다.
import com.squareup.moshi.Json

data class ResponseAddress(
        @field:Json(name = "result") val result : Address
)

data class Address (
        @field:Json(name = "total") val total : Int,
        @field:Json(name = "items") val items : List<AddressItem>
)

data class AddressItem (
        @field:Json(name = "address") val address : String,
        @field:Json(name = "addrdetail") val addressDetail : AddressDetail
)

data class AddressDetail (
        @field:Json(name = "country") val country : String,
        @field:Json(name = "sido") val sido : String,
        @field:Json(name = "sigugun") val sigugun : String,
        @field:Json(name = "dongmyun") val dongmyun : String,
        @field:Json(name = "rest") val rest : String
)

Source 클래스 구현

  • Repository 에서 구현될 모델 클래스들의 공통적인 함수 모음
interface Source {
    
    fun getConvertedAddr(lat : Double, lng : Double ) : Observable<Address>

}
  • FirebaseRepository by Source
class FirebaseRepository : Source{
    val firestoreApp by lazy {
        FirebaseFirestore.getInstance()
    }
    
    override fun getConvertedAddr(lat: Double, lng: Double) : Observable<Address> {
        TODO("not implemented") //To change body of created functions use File | Settings | File
    }
}
  • NetworkRepository by Source
object NetworkRepsitory : Source {
    
    override fun getConvertedAddr(lng: Double, lat: Double): Observable<Address> {
        //129.075090,35.179632
        return ApiManager.getAddressFromLatLng(lng, lat)
    }
}
  • RepositoryImpl 를 통해서 Presenter와 통신한다.
    • NetworkRepository 에서 주소를 변환하도록 지시
class RepositoryImpl {

    fun convertAddr(lng: Double, lat: Double): Observable<Address> = NetworkRepsitory.getConvertedAddr(lng, lat)
}

Retrofit API Manager

  • Retrofit Builder 구현
object BaseApiManager {
    fun initRetrofit(Server : String): Retrofit {
        val interceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }

        val client = OkHttpClient.Builder().apply {
            networkInterceptors().add(Interceptor { chain ->
                val original = chain.request()
                val request = original.newBuilder()
                        .method(original.method(), original.body())
                        .build()
                chain.proceed(request)
            })
            addInterceptor(interceptor)
        }

        return Retrofit.Builder().baseUrl(Server)
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(BaseApiManager.createMoshiConverter())
                .client(client.build())
                .build()
    }

    private fun createMoshiConverter(): MoshiConverterFactory = MoshiConverterFactory.create()
  • 중요한 부분

    • RxJava2CallAdapterFactory 를 통해서 컨버팅 된 결과물을 Observable 를 스트림으로 바꿔준다.
    • BaseApiManager.createMoshiConverter() 을 통해 GSON 컨버팅을 진행한다.
            return Retrofit.Builder().baseUrl(Server)
                  .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                  .addConverterFactory(BaseApiManager.createMoshiConverter())
                  .client(client.build())
                  .build()

네이버 지코코딩을 통해 좌표 -> 주소로 변환

  • subscribeOn 은 RxJava 구독 시작시 쓰레드를 결정시킨다.
  • Network 를 사용시 io 쓰레드 이용해서 통신
  • 결과물은 Observable<Address> 으로 리턴된다.
object ApiManager {
    private const val BASE_SERVER: String = "https://openapi.naver.com/v1/map/"
    var retrofit: Retrofit

    init {        
        retrofit = BaseApiManager.initRetrofit(BASE_SERVER)
    }

    fun getAddressFromLatLng(lng: Double, lat: Double) : Observable<Address> {
        val addressService = retrofit.create(AddressService::class.java)
        
        return addressService.getAddress(query = "$lat,$lng")
                .map{ it.result } //ResponseAddress -> Address
                .subscribeOn(Schedulers.io())
    }
}

API 인터페이스를 통해서 통신 처리

  • ResponseAddress -> Address -> AddressItem -> AddressDetail 으로 오브젝트들을 변환시킨다.
interface AddressService {

    /*
    위도 : latitude
    경도 : longtitude
    "https://openapi.naver.com/v1/map/reversegeocode?encoding=utf-8&coordType=latlng&query=";
     */
    @Headers(
            "X-Naver-Client-Id:네이버API",
            "X-Naver-Client-Secret:네이버API"
    )
    @GET("reversegeocode?encoding=utf-8&coordType=latlng")
    fun getAddress(@Query("query") query : String) : Observable<ResponseAddress>
}

Presenter로 통해서 결과값을 UI 로 처리

  • observeOn(AndroidSchedulers.mainThread()) 을 통해서 해당 밑으로는 Android UI Thread 로 처리
  • mView 를 통해서 updateAddress 해줌으로써 데이터를 가져와서 UI 로 올려보내는 작업은 끝났다.
override fun getAddr(lng: Double, lat: Double) {        
        val disposable = repository.convertAddr(lng, lat)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                        onNext = {                            
                            mView?.updateAddress(it.items[0].addressDetail.dongmyun)
                        },
                        onError = {
                            it.printStackTrace()
                        }
                )

        compositeDisposable.add(disposable)
    }

RecyclerViewAdapter 이벤트 리스너 구현

  • Adapter에 리스너 붙이는 작업
  • Kotlin 확장함수를 통해서 ViewHolder 클래스를 이용해서 listen 함수 구현
fun <T : RecyclerView.ViewHolder> T.listen(event: (position: Int, type: Int) -> Unit): T {
    itemView.setOnClickListener {
        event.invoke(adapterPosition, itemViewType)
    }
    return this
}
  • Adapter 부분
    • clickListener는 MainActivity에서 콜백 구현
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainRecylerViewAdapter.ViewHolder {
        val view = LayoutInflater.from(context).inflate(R.layout.main_recyclerview_item, parent, false)
        return ViewHolder(view).listen{ pos, type ->
            clickListener(items[pos])
        }
    }
  • MainActivity
    • MainActivity 에서 아이템 클릭시 리스트 화면으로 넘어가기 위해 필요
...
mainRecyclerView.adapter = MainRecylerViewAdapter(items, getContext(), ::handleItem)
...


fun handleItem(item: Category) {
    startActivity(
        Intent(this, ListActivity::class.java).apply {
            this.putExtra("item", item)
        }
    )
}

이상으로 2주차 부산 배달앱 클론앱 스터디 내용이었습니다.

다음주에는 3주차로 리스트 화면 상세 구현 및 등록 등을 진행할 예정입니다.

도커 2주차 수업

복습

http://javaexpert.tistory.com/967?category=719756

docker-compose를 만드는 방법에 대해 진행

1. package.json

{
    "dependencies": {
        "express": "*",
        "redis": "2.8.0"
    },
    "scripts": {
        "start": "node index.js"
    }
}

2. index.js

  • Docker compose를 통해서 내부 네트워크 설정을 진행
  • redis를 연결시 redis-server을 바로 호출 해서 연결함
  • process를 강제로 죽는 오류를 발생시켜서 도커 컨테이너상에서 오류에 대한 여러가지 대처방법을 배움
  • "no",always,on-failure등 여러가지 프로세스 오류에 대처할 수 있음

const express = require("express"); const redis = require('redis'); const process = require('process'); const app = express(); const client = redis.createClient({ host: "redis-server", port: 6379 }); client.set("visits", 0); app.get("/", (req, res) => { process.exit(1); //오류 발생시 자동 재시작되는지 체크 client.get("visits", (err, visits) => { if(err != null) { console.error(err); } res.send("Number of visits is " + visits); client.set('visits', parseInt(visits) + 1); }); }); app.listen(8081, () => { console.log('Listening on port2 8081'); });

3. Dockerfile

  • alpine 노드 이미지 사용
  • /app 에 작업 폴더 만들고 진행
  • package.json을 복사해서 이 부분만 캐싱 되서 변경될 경우 npm install 진행
  • 소스를 복사
  • npm start 명령어 실행
FROM node:alpine

WORKDIR '/app'

COPY package.json .
RUN npm install

COPY . .

CMD ["npm", "start"]

4.docker-compose.yml

version: '3'
services:
  redis-server:
    image: 'redis'
  node-app:
    restart: on-failure
    build: .
    ports:
      - "4001:8081" 

참고사항

  • docker-compose up 을 통해서 올릴수 있음
  • Dockerfile 내용이 변경된 경우 docker-compose up --build를 통해서 다시 빌드해서 올릴 수 있음
  • 내부 소스 파일 내용이 변경된 경우에도 build로 통해서 진행해야함

여기까지 2주차를 진행했으며 다음시간에 여러 컨테이너에 올리는 방법에 대해서 배워보자.

Stateful Widget Lifecycle

https://flutterbyexample.com/stateful-widget-lifecycle/#6-didupdatewidget 을 공부해서 요약한 글입니다.
자세한 내용은 원문확인 부탁드립니다.

StatefulWidget을 만들때 State 라는 오브젝트를 만든다. 이 오브젝트는 위젯이 동작하는 동안 mutable state를 뜻한다.

state의 컨셉은 두가지로 정의되어 진다.

  1. 위젯에 의해서 사용되어지는 데이터는 변할수 있다.
  2. 위젯이 빌드 될때 데이터들을 동기적으로 읽을수 없다. 모든 State 들은 build 함수가 호출될 때까지 설정되어야 한다.

StatefulWidget 와 State 는 클래스를 분리를 한 이유는?

성능때문이다.

1. createState()

Framework가 StatefulWidget을 만들경우 createState() 가 즉시 호출된다.

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

2. mounted is true

createState 함수는 buildContext 가 state 에 할당되게 된다. BuildContext 는 위젯트리를 단순하기 위해 필요한 것이다. (좀 더 공부 필요)

모든 위젯은 this.mounted : bool 속성을 가지고 있다. 즉 buildContext 가 할달될 때 this.mounted 가 true 로 리턴된다.

위젯이 unmounted 일 경우에는 setState 를 부를 경우 에러가 발생될 수 있습니다.

이 속성은 중요하다고 볼 수 있습니다. setState() 함수를 호출시 만약 비동기로 시간이 지연되는 경우 widget이 마운트가 해제될 수 있기 때문에 if(mounted) {...} 을 사용해서 setState() 를 하는 걸 추천하다.

3. initState()

widget이 만들어지고 생성자 후에 처음 메소드 실행할때 이 함수가 실행된다. super.initState() 를 필수적으로 호출해야 한다.

이 함수를 호출할 때 최고의 선택은

  • 위젯 인스턴스를 만들기 위해 BuildContext 를 이용해서 데이터들을 초기화를 할 경우
  • 위젯 트리에서 부모 위젯의 속성을 초기화할때
  • 스트림을 구독(리스닝) 할 때
@override
initState() {
  super.initState();
  // Add listeners to this class
  cartItemStream.listen((data) {
    _updateWidget(data);
  });
}

4. didChangeDependencies()

이 함수는 initState를 호출한 뒤에 실행된다. 또한 이 위젯은 데이터에 의존하는 객체가 호출될 때마다 호출됩니다. InheritedWidget 에 의존하는 경우 업데이틀 합니다.

build 는 항상 didChangeDependencies 호출 후에 실행되는 점을 명심하자. 잘 사용하지 않지만 BuildContext.inheritFromWidgetOfExactType 을 호출하기 위해서 첫단계 시작점이다. 이건 데이터를 상속받는 위젯의 변경사항을 리스닝 하게 만든다.

이 함수는 상속된 위젯이 업데이트를 하는 경우 당신이 네트워크 호출이라던가 그런 코스트가 많이 드는 액션을 할 때 유용하다.

5. build()

필수적으로 오버라이딩해서 구현해야되는 함수이다. 위젯을 리턴한다.

6. didUpdateWidget(Widget oldWidget)

만약 부모 위젯이 업데이트가 되거나 이 위젯이 다시 만들 경우 이 함수가 호출되고 같은 runtimeType (이건 또 뭐징?) 을 함께 다시 만들어진다.

Flutter는 state를 재사용한다. 이 경우 initState 에서 값을 초기화를 해야 할수 있다.

build 함수가 변경 할 수 있는 스트림이나 다른 개체를 사용하는 경우 이전 개체에서 구독을 취소하고 didUpdateWidget 에서 새 인스턴스에 다시 구독합니다.

이 메소드는 기본적으로 상태와 관련된 위젯이 rebuild 될 것으로 예상 되는 경우 initState 를 대체합니다.

@override
void didUpdateWidget(Widget oldWidget) {
  if (oldWidget.importantProperty != widget.importantProperty) {
    _init();
  }
}

8. deactivate()

9. dispose()

영구적인 State Object가 삭제될때 호출된다. 이 함수는 주로 Stream 이나 애니메이션 을 해제시 사용된다.

10. mounted is false

state object가 다시 마운팅 되지 않을 경우 setState 호출시 에러를 리턴한다.

Transform 활용

Stream 사용시 주어진 값들을 이용해서 가공할 일이 생길수 있다. 
그럴경우 Transform 을 활용해서 가능하다.

우선 예제를 보면서 살펴보자.

  • 이메일을 넣는 경우 유효성 체크 하는 validate stream 을 만든다고 가정하자.
  • '@' 포함되는 경우 정상, 없는 경우 오류로 리턴하는 아주 간단한 예제

우선 bloc 패턴을 이용할 예정이다.

  • bloc 생성하자.
  • StreamController 생성
  • sink 정의
  • transform 정의
...

final bloc = new Bloc();

...

class Bloc {
    final emailController = StreamController<String>();
    
    Function(String) get changeEmail => emailController.sink.add;
    
    Stream<String> get email => emailController.stream.transform(validateEmail);
    
    final validateEmail = 
        StreamTransformer<String, String>.fromHandlers(handleData: (email, sink) {
        if(email.contains('@')){
            sink.add(email); //정상
        } else {
            sink.addError('Enter a valid email'); //실패
        }        
        });
}
  • Main 함수 정의
  bloc.email.listen((value) {
    print(value);
  });
  
  bloc.changeEmail('test'); // => Error
  bloc.changeEmail('test@test.net'); // => 정상

전체 소스는 다음과 같다.

import 'dart:async';

void main() {
  final bloc = new Bloc();
    
  bloc.email.listen((value) {
    print(value);
  });
  
  bloc.changeEmail('test@test.net');
}

class Bloc {
  final emailController = StreamController<String>();
    
  Function(String) get changeEmail => emailController.sink.add;
  
  Stream<String> get email => emailController.stream.transform(validateEmail);
  
  final validateEmail = 
    StreamTransformer<String, String>.fromHandlers(handleData: (email, sink) {
      if(email.contains('@')){
       sink.add(email); 
      } else {
        sink.addError('Enter a valid email');
      }        
    });
  
}


Android 에서 WebView사용시 정말 XX 한게 파일 업로드가 아닐수 없다. 

이부분에서 괜찮은 소스가 보여서 공유 해본다. 

webView.setWebChromeClient(new WebChromeClient()
        {
            //The undocumented magic method override
            //Eclipse will swear at you if you try to put @Override here
            // For Android 3.0+
            public void openFileChooser(ValueCallback<Uri> uploadMsg) {

                mUploadMessage = uploadMsg;
                Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                i.addCategory(Intent.CATEGORY_OPENABLE);
                i.setType("image/*");
                activity.startActivityForResult(Intent.createChooser(i,"File Chooser"), FILECHOOSER_RESULTCODE);

            }

            // For Android 3.0+
            public void openFileChooser( ValueCallback uploadMsg, String acceptType ) {
                mUploadMessage = uploadMsg;
                Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                i.addCategory(Intent.CATEGORY_OPENABLE);
                i.setType("*/*");
               activity.startActivityForResult(
                        Intent.createChooser(i, "File Browser"),
                        FILECHOOSER_RESULTCODE);
            }

            //For Android 4.1
            public void openFileChooser(ValueCallback<Uri> uploadMsg, String acceptType, String capture){
                mUploadMessage = uploadMsg;
                Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                i.addCategory(Intent.CATEGORY_OPENABLE);
                i.setType("image/*");
                activity.startActivityForResult( Intent.createChooser( i, "File Chooser" ), FILECHOOSER_RESULTCODE );

            }

            //For Android 5.0+
            public boolean onShowFileChooser(
                    WebView webView, ValueCallback<Uri[]> filePathCallback,
                    FileChooserParams fileChooserParams){
                if(mUploadMessageArray != null){
                    mUploadMessageArray.onReceiveValue(null);
                }
                mUploadMessageArray = filePathCallback;

                Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
                contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE);
                contentSelectionIntent.setType("*/*");
                Intent[] intentArray;
                intentArray = new Intent[0];

                Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
                chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent);
                chooserIntent.putExtra(Intent.EXTRA_TITLE, "Image Chooser");
                chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray);
                activity.startActivityForResult(chooserIntent, FILECHOOSER_RESULTCODE);
                return true;
            }
        });


Android 배달앱 클론 스터디


부산에서 진행한 배달앱 클론을 진행하면서 공부한 내용을 정리 ( 1주차 )

목표

Android로 배달 앱을 클론 하면서 개발하는 방법과 패턴, RxJava등 사용법을 배우게 된다.

MVP flow

mvp flow mvp flow

Base MVP

View

화면 단위를 뜻한다. Fragment, Adapter, Activity

Model

데이터 연결 하는 부분을 담당한다. Sqlite, Retrofit등

Presenter

View <-> Model 간의 연결을 도와주는 역할을 한다.

Base 작성

BaseMvpView

interface BaseMvpView {

    fun getContext(): Context

    fun showError(error: String?)

    fun showError(@StringRes stringResId: Int)

    fun showMessage(@StringRes strResId: Int)

    fun showMessage(message: String)
}

BaseMvpPresenter

interface BaseMvpPresenter<in V : BaseMvpView> {

    fun attachView(view: V)

    fun detachView()
}

BaseMvpPresenerImpl

  • BaseMvpPresenter를 상속받아서 구현합니다.
open class BaseMvpPresenterImpl<T: BaseMvpView> : BaseMvpPresenter<T> {

    protected var mView: T? = null

    override fun attachView(view: T) {
        mView = view
    }

    override fun detachView() {
        mView = null
    }

}

BaseMvpActivity

abstract class BaseMvpActivity<in V : BaseMvpView, T : BaseMvpPresenter<V>>
    : AppCompatActivity(), BaseMvpView {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mPresenter.attachView(this as V)

        title = ""
    }

    override fun getContext(): Context = this

    protected abstract var mPresenter: T

    override fun showError(error: String?) {
        Toast.makeText(this, error, Toast.LENGTH_LONG).show()
    }

    override fun showError(stringResId: Int) {
        Toast.makeText(this, stringResId, Toast.LENGTH_LONG).show()
    }

    override fun showMessage(srtResId: Int) {
        Toast.makeText(this, srtResId, Toast.LENGTH_LONG).show()
    }

    override fun showMessage(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_LONG).show()
    }

    override fun onDestroy() {
        super.onDestroy()
        mPresenter.detachView()
    }
}

그럼 간단한 앱을 만들어보자. 

전체적인 흐름은 다음과 같다.

  1. mvp 코어들을 상속받은 메인 화면을 만든다.
  2. 그안에 버튼을 삽입한다.
  3. 버튼클릭시 카운트가 하나씩 올라간다.
  4. 카운트는 View -> Presenter -> Model 로 내려갔다가 반대로 View까지 푸시되어서 화면이 update 되는 과정을 거친다.
  5. 카운트는 Firebase 에서 제공해주는 Firestore를 이용한다.

MVP 코어들을 이용해서 메인 화면을 만든다. 

  • MainContract
  • MainPresenter
  • MainActivity

MainContract를 만든다.

  • object 클래스는 싱글톤 클래스를 뜻한다.
object MainContract {
    interface View : BaseMvpView {
        fun updateView(count : Int)
    }

    interface Presenter : BaseMvpPresenter<View> {
        fun addCount(count : Int)
    }
}

MainPresenter를 만든다.

  • BaseMvpPresenterImpl를 상속받아서 만드는 걸 유의하자.
  • repository 패턴을 이용한다.
  • addCount 를 통해서 repository.addCount 를 통해서 값을 증가 시킨다.
  • 그리고 repository 를 통해서 값을 가져온다.
    • 일부러 버그 유발 코드를 만들어서 오류를 확인해본다..
  • 마지막으로 화면에 업데이트 한다.
    • 지금 구조에서는 값이 실시간으로 파베에 저장이 되고 가져오질 않는다.
    • 비동기라는 걸 이해하자.
class MainPresenter : BaseMvpPresenterImpl<MainContract.View>(), MainContract.Presenter {

    private val repository : Repository by lazy {
        RepositoryImpl()
    }

    override fun addCount(count: Int) {
        repository.addCount(count)

        val result = repository.getCount()

        mView?.updateView(result)
    }
}

MainActivity를 만든다.

  • BaseMvpActivity 를 상속받는다.
  • MainContract.View 를 상속받아서 updateView를 구현하도록 한다.
  • BaseMvpActivity에서 추상화된 mPresenter 인스턴스를 생성하게끔 한다.
  • 버튼을 클릭시 mPresenter.addCount(Integer.parseInt(resultTxt.text.toString())) 를 통해서 presenter로 값을 추가한다.
  • updateView 를 통해서 View 화면을 업데이트 하도록 한다.
class MainActivity : BaseMvpActivity<MainContract.View, MainContract.Presenter>(), MainContract.View, NavigationView.OnNavigationItemSelectedListener {

    override var mPresenter: MainContract.Presenter = MainPresenter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(toolbar)

        val toggle = ActionBarDrawerToggle(
                this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
        drawer_layout.addDrawerListener(toggle)
        toggle.syncState()

        nav_view.setNavigationItemSelectedListener(this)

        button.setOnClickListener {
            mPresenter.addCount(Integer.parseInt(resultTxt.text.toString()))
        }
    }

    override fun onBackPressed() {
        if (drawer_layout.isDrawerOpen(GravityCompat.START)) {
            drawer_layout.closeDrawer(GravityCompat.START)
        } else {
            super.onBackPressed()
        }
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        // Inflate the menu; this adds items to the action bar if it is present.
        menuInflater.inflate(R.menu.main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        when (item.itemId) {
            R.id.action_settings -> return true
            else -> return super.onOptionsItemSelected(item)
        }
    }

    override fun onNavigationItemSelected(item: MenuItem): Boolean {
        // Handle navigation view item clicks here.
        when (item.itemId) {
            R.id.nav_camera -> {
                // Handle the camera action
            }
            R.id.nav_gallery -> {

            }
            R.id.nav_slideshow -> {

            }
            R.id.nav_manage -> {

            }
            R.id.nav_share -> {

            }
            R.id.nav_send -> {

            }
        }

        drawer_layout.closeDrawer(GravityCompat.START)
        return true
    }

    override fun updateView(count: Int) {
        resultTxt.text = count.toString()
    }
}

Firebase 에 있는 Firestore를 이용해 보자.

  1. https://firebase.google.com/ 를 통해서 로그인 후 프로젝트를 만든다.
  2. 콘솔로 가서 프로젝트 설정을 통해서 앱 추가를 한다.
  3. 패키지명을 적어주고 google-services.json 파일을 받는다.
  4. 다시 프로젝트로 와서 app 폴더에 방금 받은 파일을 추가해준다.
  5. https://firebase.google.com/docs/android/setup?authuser=0 을 참고해서 dependencies를 추가한다.
  6. firebase-core 를 꼭 추가해주는 걸 잊지 말자.
  7. firestore 문서로 가서 android 를 클릭해서 라이버러리를 추가해준다.
    1. compile 'com.google.firebase:firebase-firestore:15.0.0 을 추가 해주는 걸 잊지 말자.

Model 부분 추가

Repository 를 추가해보자.

  1. Repository 사용될 함수들 인터페이스 정의
  2. RepositoryImpl 구현
  3. FirebaseRepository 를 구현

Repository

interface Repository {

    fun addCount(count: Int)
    fun getCount() : Int

}

RepositoryImpl

class RepositoryImpl : Repository{

    val firebaseRepo : Repository by lazy {
        FirebaseRepository()
    }

    var result = 0

    override fun addCount(count: Int) {
        result = count + 1

        firebaseRepo.addCount(result);

    }

    override fun getCount() : Int {
        return result
    }
}

FirebaseRepository

  • map 형태로 넣을 수 있다.
  • 콜백형태로 값을 핸들링 가능핟.
class FirebaseRepository : Repository{
    val firestoreApp by lazy {
        FirebaseFirestore.getInstance()
    }

    override fun addCount(count: Int) {
        val map = mapOf("value" to count)

        firestoreApp.collection("test").document("count")
        .set(map)
        .addOnSuccessListener {
            Log.d("firebase", "success add with $count")
        }
        .addOnFailureListener{
            Log.d("firebase", "Error adding count")
        }

    }

    override fun getCount() : Int {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}


Relative Programming - BLoC 패턴

아래 내용은 https://www.didierboelens.com/2018/08/reactive-programming---streams---bloc/ 을 공부하고 요약해놓은 글입니다.

BLoC Pattern 은 구글 개발자 Paolo Soares 와 Cong Hui 에 의해서 디자인 되었다. 그리고 처음 발표된 건 2018년 DartConf 이다.

관련 영상

BLoC = Business Logic Component.

  • 하나 또는 여러개의 BLoC가 존재할수 있다.
  • Presentation Layer(UI 부분)에서 가능하한 제거되며 로직부분만 분리해서 테스트가 용이함
  • 플랫폼 종속적이지 않다.
  • 환경에 종속적이지 않다.

BLoC 패턴은 스트림을 이용해서 만들어진다.

  • 위젯은 Sinks 를 통해서 Bloc에 이벤트를 보낸다.
  • 위젯은 Bloc에 있는 스트림을 통해서 결과를 통지 받는다.
  • BLoC에 의해서 비즈니스 로직들은 재사용이 가능하다.
  • UI 쪽에서는 BLoC 에 대해서 알 필요가 없다.

그럼 적용은 어떻게 하는 걸까?

기본적으로 앱 플젝 새로 설치시 나오는 카운트 올리는 걸 수정해보자.

Widget에 BlocProvider를 만들어준다.

home: BlocProvider<IncrementBloc>(
  bloc: IncrementBloc(),
  child: CounterPage(),
),

Bloc 인스턴스를 생성한다.

final IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

리스닝하고 싶은 위젯에 등록한다.

 body: Center(
    child: StreamBuilder<int>(
      stream: bloc.outCounter,
      initialData: 0,
      builder: (BuildContext context, AsyncSnapshot<int> snapshot){
        return Text('You hit me: ${snapshot.data} times');
      }
    ),
  ),

그리고 BLoC 파일을 작성해준다.

class IncrementBloc implements BlocBase {
  int _counter;

  //
  // Stream to handle the counter
  //
  StreamController<int> _counterController = StreamController<int>();
  StreamSink<int> get _inAdd => _counterController.sink;
  Stream<int> get outCounter => _counterController.stream;

  //
  // Stream to handle the action on the counter
  //
  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  //
  // Constructor
  //
  IncrementBloc(){
    _counter = 0;
    _actionController.stream
                     .listen(_handleLogic);
  }

  void dispose(){
    _actionController.close();
    _counterController.close();
  }

  void _handleLogic(data){
    _counter = _counter + 1;
    _inAdd.add(_counter);
  }
}

카운트를 올리는 이벤트를 스트림에 보낸다. (인풋)

bloc.incrementCounter.add(null);

Provider도 작성해준다.

// Generic Interface for all BLoCs
abstract class BlocBase {
  void dispose();
}

// Generic BLoC provider
class BlocProvider<T extends BlocBase> extends StatefulWidget {
  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

전체 소스는 다음과 같다.

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
        title: 'Streams Demo',
        theme: new ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: BlocProvider<IncrementBloc>(
          bloc: IncrementBloc(),
          child: CounterPage(),
        ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context);

    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(
          stream: bloc.outCounter,
          initialData: 0,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          bloc.incrementCounter.add(null);
        },
      ),
    );
  }
}

class IncrementBloc implements BlocBase {
  int _counter;

  //
  // Stream to handle the counter
  //
  StreamController<int> _counterController = StreamController<int>();
  StreamSink<int> get _inAdd => _counterController.sink;
  Stream<int> get outCounter => _counterController.stream;

  //
  // Stream to handle the action on the counter
  //
  StreamController _actionController = StreamController();
  StreamSink get incrementCounter => _actionController.sink;

  //
  // Constructor
  //
  IncrementBloc(){
    _counter = 0;
    _actionController.stream
                     .listen(_handleLogic);
  }

  void dispose(){
    _actionController.close();
    _counterController.close();
  }

  void _handleLogic(data){
    _counter = _counter + 1;
    _inAdd.add(_counter);
  }
}

///
// Generic Interface for all BLoCs
abstract class BlocBase {
  void dispose();
}

// Generic BLoC provider
class BlocProvider<T extends BlocBase> extends StatefulWidget {
  BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends BlocBase>(BuildContext context){
    final type = _typeOf<BlocProvider<T>>();
    BlocProvider<T> provider = context.ancestorWidgetOfExactType(type);
    return provider.bloc;
  }

  static Type _typeOf<T>() => T;
}

class _BlocProviderState<T> extends State<BlocProvider<BlocBase>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

만약 여러개의 위젯에 BLoC을 붙일수 있나?

child 형태로 붙일수 있다.

void main() => runApp(
  BlocProvider<ApplicationBloc>(
    bloc: ApplicationBloc(),
    child: MyApp(),
  )
);

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    return MaterialApp(
      title: 'Streams Demo',
      home: BlocProvider<IncrementBloc>(
        bloc: IncrementBloc(),
        child: CounterPage(),
      ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context){
    final IncrementBloc counterBloc = BlocProvider.of<IncrementBloc>(context);
    final ApplicationBloc appBloc = BlocProvider.of<ApplicationBloc>(context);

    ...
  }
}



Reactive Programming part 1 - Stream

스트림이란 무엇인가?

스트림을 이해하기 위해선 파이프를 상상해야 한다.

뭔가를 입력을 했다면 그것이 파이프 안에서 흘러서 다른 출구쪽으로 배출 되는 걸 뜻한다.

그럼 Flutter 을 대입해보자면

  1. 파이프는 Stream 이라고 부른다.
  2. Stream을 제어하기위해 우리는 자주 StreamController 를 이용한다.
  3. Stream 입력하기 하기 위해 StreamSink 를 사용한다.

입력 할수 있는 것들은 어떤 것이든 가능하다.

데이터, 오브젝트, map, 에러코드, 이벤트, 심지어 다른 스트림도 가능하다.

배출은 어떻게 확인하나?

일단 입력이 되면 Rx 처럼 subscribe 를 통해서 받을 수 있다. 이 과정을 flutter에서는 listen 한다고 말한다.

**즉 Stream을 listen 하면 StreamSubscription 오브젝트를 받을수 있는 것이다. **

StreamSubscription이 할수 있는 것들은

  • Stop listening
  • pause
  • resume

그럼 스트림은 단순히 파이프 역할만 하는 건가?

스트림은 또한 흐르는 데이터들을 **process 를 허용하고 있다. **

즉 **StreamTransformer **를 통해서 데이터를 조작하고 그것을 다시 배출 하는 과정을 거친다.

**StreamTransformer 은 프로세싱하는 데 어떠한 타입 형태로도 사용 가능하다. **

예를 들어 Filtering, regrouping, modification 등등

Streams 의 종류

Single-subscription Streams

  • 하나의 리스너를 통해서만 값을 배출 받을수 있다. 그 후 리스너들은 취소될 것이다.
import 'dart:async';

void main() {
  //step 1 StreamController 등록
  final StreamController ctrl = StreamController();

  //step 2 listen 등록
  final StreamSubscription subscription = ctrl.stream.listen((data) => print('$data'));

  //step 3 streamcontroller 에 sink를 통해 입력
  ctrl.sink.add('Hello Stream');
  ctrl.sink.add(1004);
  ctrl.sink.add({'1':100,'2':'200'});

  //사용 종류 후 close()
  ctrl.close();
}

Broadcast Streams

  • 다수의 리스너에 동시에 배출을 할 수 있다.
import 'dart:async';

void main() {
  final StreamController ctrl = StreamController<int>.broadcast();

  ctrl.stream
    .where((value) => value % 2 == 0) //StreamTransformer 적용함
    .listen((data) => print('$data'));

  for(int i=0; i< 10; i++) {
    ctrl.sink.add(i);
  }

  ctrl.close();
}

그러면 Flutter에서는 연동을 어떻게 할까?

Flutter Widget에서는 StreamBuilder라고 지원해준다.

StreamBuilder<T>(
    key: ...optional, the unique ID of this Widget...
    stream: ...the stream to listen to...
    initialData: ...any initial data, in case the stream would initially be empty...
    builder: (BuildContext context, AsyncSnapshot<T> snapshot){
        if (snapshot.hasData){
            return ...the Widget to be built based on snapshot.data
        }
        return ...the Widget to be built if no data is available
    },
)

StreamBuilder를 이용해서 Counter 를 1씩 올리는 예제

import 'dart:async';
import 'package:flutter/material.dart';

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;
  //Step1 StreamController 선언
  final StreamController<int> _streamController = StreamController<int>();

  @override
  void dispose(){
    _streamController.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Stream version of the Counter App')),
      body: Center(
        child: StreamBuilder<int>(
          //Step2 Stream Listener 등록
          stream: _streamController.stream,
          initialData: _counter,
          builder: (BuildContext context, AsyncSnapshot<int> snapshot){
            return Text('You hit me: ${snapshot.data} times');
          }
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: (){
          //StreamController에 데이터 입력
          _streamController.sink.add(++_counter);
        },
      ),
    );
  }
}

그럼 다음 시간에는 Bloc 패턴을 배워보는 시간을 가져보자.

참고 내용 : https://www.didierboelens.com/2018/08/reactive-programming---streams---bloc/

NodeJS 로 도커 시스템 구축

package.json

{
    "dependencies": {
        "express": "*"
    },
    "scripts": {
        "start": "node index.js"
    }
}

index.js

const express = require('express');

const app = express();

app.get('/', (req, res) => {
    res.send('Hi there');
});

app.listen(8080, () => {
    console.log('Listening on port 8080');
});

Dockerfile

# Specify a base image From node:alpine # install some depenendencies RUN npm install # Default command CMD ["npm", "start"]

실행시 node를 찾을수가 없다. 이유는 alpine은 리눅스 기본 이미지이기 때문에 node가 포함되어 있지 않다.
node:alpine을 지정해서 사용할 수 있다.

> docker build .

npm WARN saveError ENOENT: no such file or directory, open '/package.json'
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN enoent ENOENT: no such file or directory, open '/package.json'
npm WARN !invalid#2 No description
npm WARN !invalid#2 No repository field.
npm WARN !invalid#2 No README data
npm WARN !invalid#2 No license field.

node:alpine container 에는 package.json 파일이 포함되어 있지 않다. 그래서 오류가 표시됨

해결방법 : Docker image에 파일을 복사하는 명령어를 붙인다.

# Specify a base image
FROM node:alpine

# install some depenendencies
COPY ./ ./
RUN npm install

# Default command
CMD ["npm", "start"] 

이지지를 commit 오류가 나올텐데 이미지를 생성하는 명령어를 붙여보자.

>> docker build -t gdgbusan/simpleweb .

Sending build context to Docker daemon  66.05kB
Step 1/4 : FROM node:alpine
 ---> 5206c0dd451a
Step 2/4 : COPY ./ ./
 ---> Using cache
 ---> d0fe38353187
Step 3/4 : RUN npm install
 ---> Using cache
 ---> 893984d57822
Step 4/4 : CMD ["npm": "start"]
 ---> Using cache
 ---> e7716218c007
Successfully built e7716218c007
Successfully tagged gdgbusan/simpleweb:latest
>> docker images

....
gdgbusan/simpleweb   latest              e7716218c007        2 minutes ago       72.9MB
....

이제 실행을 해보자.

docker run gdgbusan/simpleweb


> @ start /
> node index.js

Listening on port 8080

잘 되는 걸 확인 할 수 있다.

포트 바인딩 문제

Web에서 호출시 페이지를 찾을수 없는 오류가 나올것이다. 
localhost 에서는 8080으로 호출하지만 docker 에는 8080으로 바인딩이 안되어 있기 때문에 오류가 나는 것이다.

>> docker run -p 8080:8080 gdgbusan/simpleweb

작업 디렉토리 문제

빌드를 하는 경우 기존 파일이나 폴더등이 겹칠수 있는 문제가 발생할 수 있다.

>> docker run -it gdgbusan/simpleweb sh
/ # ls
Dockerfile         bin                etc                index.js           media              node_modules       package-lock.json  proc               run                srv                tmp                var
README.md          dev                home               lib                mnt                opt                package.json       root               sbin               sys                usr

위 내용에서 만약 lib 또는 usr 라는 폴더가 로컬에서 작업 디렉토리로 있는 경우 문제가 될 수 있다.

해결 방안

작업 영역을 따로 지정해줘서 소스를 다 옮겨준다.

.Dockerfile

# Specify a base image
FROM node:alpine

## Define WORKDIR
WORKDIR /user/app

# install some depenendencies
COPY ./ ./
RUN npm install

# Default command
CMD ["npm", "start"] 
> docker run -it gdgbusan/simpleweb sh

/user/app # ls
Dockerfile         README.md          index.js           node_modules       package-lock.json  package.json

/user/app 으로 들어간걸 볼수 있다.

Container ID 를 이용해서 접속 할 수 있다.

>> docker ps
CONTAINER ID        IMAGE                COMMAND             CREATED             STATUS              PORTS                    NAMES
570087c1cc0d        gdgbusan/simpleweb   "npm start"         4 minutes ago       Up 4 minutes        0.0.0.0:8080->8080/tcp   sleepy_archimedes
>> docker exec -it 5700 sh
/user/app # ls
Dockerfile         README.md          index.js           node_modules       package-lock.json  package.json

작업내용 변경 문제

서버쪽에서 변경 사항이 생긴 경우 전체 파일이 변경되서 npm install을 해줘야 하는 경우가 생긴다. 이런 경우를 대비해서 package.json 의 경우에만 캐싱을 따로 처리해주자.

# Specify a base image
FROM node:alpine

WORKDIR /user/app

# install some depenendencies
COPY ./package.json ./
RUN npm install
# Caching
COPY ./ ./

# Default command
CMD ["npm", "start"] 

index.js 에서 변경된 경우 의존성은 캐싱되서 그대로고 소스만 변경해서 만들어준다.

> Dockerfile

PS G:\workspace_docker\simpleweb> docker build -t gdgbusan/simpleweb .
Sending build context to Docker daemon  69.63kB
Step 1/6 : FROM node:alpine
 ---> 5206c0dd451a
Step 2/6 : WORKDIR /user/app
 ---> Using cache
 ---> f86704b2b479
Step 3/6 : COPY ./package.json ./
 ---> Using cache
 ---> a2309c91cf66
Step 4/6 : RUN npm install
 ---> Using cache
 ---> 67444f1d46ea
Step 5/6 : COPY ./ ./ //<-- 캐싱이 안됨
 ---> 9e44a0ef1b60
Step 6/6 : CMD ["npm", "start"]
 ---> Using cache
 ---> cff8acc3464b
Successfully built cff8acc3464b
Successfully tagged gdgbusan/simpleweb:latest


Container 레이아웃 치트시트

https://medium.com/jlouage/container-de5b0d3ad184


Flutter Layouts Walkthrough: Row, Column, Stack, Expanded, Padding

https://medium.com/coding-with-flutter/flutter-layouts-walkthrough-row-column-stack-expanded-padding-5ed64e8f9584


Flutter Layout Cheat Sheet

https://proandroiddev.com/flutter-layout-cheat-sheet-5363348d037e


레이아웃 Flutter 영상 #1

https://codingwithflutter.com/

Flutter App Tutorial #1

이 글의 모든 내용은 udemy 강좌를 공부하고 따라해본 내용입니다.

https://www.udemy.com/dart-and-flutter-the-complete-developers-guide

완성 된 형태이다.

첫 화면에서 오른쪽 아래 플로팅 버튼 클릭시 오른쪽 화면처럼 이미지와 글자를 가진 뷰가 하나씩 추가되는 형태이다.

소스는 http json 을 통해서 가져왔다.

소스는 https://github.com/bear2u/flutter-sample-pic.git 에서 확인가능하다. 


main.dart 시작

flutter는 첫진입은 일반적으로 lib/main.dart 로 시작된다.

  • 매트리얼 테마를 사용하기 위해서 import를 한다.
  • runApp 은 메테리얼 라이버러리를 통해 실행된다.
  • App 은 따로 빼서 진행한다.
# lib/main.dart

import 'package:flutter/material.dart';
import 'src/app.dart';

void main() {  
  runApp(App());
}

lib에 src 폴더를 만들고 app.dart 를 생성한다.

  • StatefulWidget을 통해서 서버로 들어오는 아이템들을 리스트로 보내주고 있다.
  • Scaffold 는 Floating Action Button 과 타이틀바 및 바텀바등을 기본적으로 가지는 위젯이다.
  • await 는 비동기방식을 동기형태로 바꿔주고 네트워크 결과값을 가져온다.
  • 그리고 미리 정의된 ImageModel.FromJson 을 통해 디코딩을 거친다.
  • 마지막으로 setState()을 통해 images 라는 리스트에 아이템을 추가를 해서 state 변경을 한다.
# lib/src/app.dart

import 'package:flutter/material.dart';
import 'package:http/http.dart' show get;
import 'models/image_model.dart';
import 'dart:convert';
import 'widgets/image_list.dart';

class App extends StatefulWidget {
  @override
    State<StatefulWidget> createState() {
      // TODO: implement createState
      return AppState();
    }
}

class AppState extends State<App> {
  int counter = 0;
  List<ImageModel> images = [];

  void fetchImage() async {
    counter++;
    var response = await get('https://jsonplaceholder.typicode.com/photos/$counter');
    var imageModel = ImageModel.fromJson(json.decode(response.body));

    setState(() {
      images.add(imageModel);
    });

  }

  Widget build(context) {
    return MaterialApp(
      home: Scaffold(
        body: ImageList(images),
        appBar: AppBar(
          title: Text("Lets see some images!"),
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), //Widget 추가
          onPressed: fetchImage,
        ),
      ),
    );
  }
}

// Must define a 'build' method that returns
// the widgets that *this* widget will show

모델 정의

서버로 부터 가져온 JSON 결과값을 dart 내 오브젝트 형태로 변경할수 있도록 할수 있다.

# lib/src/models/image_model.dart

/**
 * JSON Decoding Model
 */
class ImageModel {
  int id;
  String url;
  String title;

  ImageModel(this.id, this.url, this.title);

  ImageModel.fromJson(Map<String, dynamic> parsedJson) {
    id = parsedJson['id'];
    url = parsedJson['url'];
    title = parsedJson['title'];
  }

  // 이렇게도 정의할 수 있다.
  // ImageModel.fromJson(Map<String, dynamic> parsedJson)
  // : id = parsedJson['id'],
  //   url = parsedJson['url'],
  //   title = parsedJson['title'];

  @override
    String toString() {
      // TODO: implement toString
      return '$id,$url,$title';
    }
}

리스트 뷰 정의

  • ListView 의 경우 React상에 바보뷰라고 하는 상태가 전혀 없고 값만 가지고 핸들링을 한다.
  • final 사용을 통해 값이 immutable 한지 체크를 할 수 있다. (바뀌면 안된다)
  • 그리고 위젯을 함수로 빼서 사용가능하다. (buildImage)
  • border의 경우 decoration 으로 가능하다.
  • 생성자로 값을 받아서 그 값을 뷰에 세팅해주고 있다.
import 'package:flutter/material.dart';
import '../models/image_model.dart';

class ImageList extends StatelessWidget {
  final List<ImageModel> images;

  ImageList(this.images);

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return ListView.builder(
        itemCount: images.length,
        itemBuilder: (context, int index) {
          return buildImage(images[index]);
        });
  }

  Widget buildImage(ImageModel image) {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey),
      ),
      padding: EdgeInsets.all(20.0),
      margin: EdgeInsets.all(20.0),
      child: Column(
        children: <Widget>[
          Padding(
            child: Image.network(image.url),
            padding: EdgeInsets.only(bottom: 10.0),
          ),        
          Text(image.title)
        ]),
    );
  }
}

이로써 아주 간단한 앱을 만들어보았다.

큰 흐름을 이해 할수 있는 소스였다.


Await / Future 비동기 함수 호출

Http 로 통신할때 일반적으로 비동기로 호출을 하는 편이다. 비동기란 함수 호출시 블럭이 되지 않고 한번 다 훝고 콜백식으로 완료가 됐을 경우 다시 결과를 받는 경우를 말한다.

만약 아래와 같은 코드에서 실행시 결과값은 마지막에 'I got the data' 로 떨어지게 된다.

그리고 자바스크립트에서 Promise 형태와 비슷한 걸 볼수 있다.

import 'dart:async';

void main () {
  print('1. start to fetch data');
  
  get('http://weasds.com')
    .then((data) {
      print(data);
    });
  
  print('3. finish call to fetch the data');
}

Future<String> get(String url) {
  return new Future.delayed(new Duration(seconds: 3), () {
    return '2. I got the data!!';
  });
}
...........
1. start to fetch data
3. finish call to fetch the data
2. I got the data!!
// 순서가 1-3-2 로 되는 걸 볼수 있다.

그리고 Promise말고도 await로 비동기를 기다렸다가 가져오는 방법도 지원한다.

import 'dart:async';

void main () async {
  print('1. start to fetch data');
  
  print(await get('http://weasds.com'));
  
  print('3. finish call to fetch the data');
}

Future<String> get(String url) {
  return new Future.delayed(new Duration(seconds: 3), () {
    return '2. I got the data!!';
  });
}

.........

1. start to fetch data
2. I got the data!! //약 3초뒤 나온다.
3. finish call to fetch the data

참고



JSON 핸들러

서버와 통신시 제일 자주 사용되는 형태가 JSON 이다. 그럼 다트에서는 어떻게 다룰수 있는 지 살펴보겠다.

테스트 환경은 https://dartpad.dartlang.org/ 에서 진행한다.

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  print(parsedJson);
  print(parsedJson['url']);

}

.............
{url: http://blah.jpg, id: 1}
http://blah.jpg

그리고 모델 클래스를 만들어서 매핑하고자 할때에는 값을 키값을 줘서 값을 가져온다음 set 을 통해서 모델을 만들수 있다.

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  var imageModel = new ImageModel(
            parsedJson['id'], 
            parsedJson['url']
  );

  print(imageModel);

}

class ImageModel {
  int id;
  String url;  

  ImageModel(this.id, this.url);

  String toString() {
    return '$id,$url';
  }
}

하지만 만약 여러개 속성을 파싱할때 이 방식은 좀 불편하다.

그래서 fromJson 함수를 제공해준다. 이 함수는 JSON 을 미리 정의된 값을 매핑해주는 역할을 한다.

그럼 코드를 보면 이해가 빠를거라 본다.

관련 링크

import 'dart:convert';

void main() {
  var rawJson = '{"url": "http://blah.jpg","id":1}';

  var parsedJson = json.decode(rawJson);
  var imageModel = new ImageModel.fromJson(parsedJson);

  print(imageModel);

}

class ImageModel {
  int id;
  String url;  

  ImageModel(this.id, this.url);

  ImageModel.fromJson(Map<String, dynamic> parsedJson) 
    : id = parsedJson['id'],
      url = parsedJson['url'];


  String toString() {
    return '$id,$url';
  }
}

............
1,http://blah.jpg


Flutter 카카오톡 오픈 채팅방 바로가기 : https://open.kakao.com/o/gsshoXJ


StatefulWidget

StatefulWidget은 위젯에 변경되는 state 를 담고 있는 커스텀 위젯을 뜻한다.

그럼 어떤식으로 만들어지는 지 살펴보자.

우선 StatefulWidget 을 extends 한다.

class App extends StatefulWidget {
    
}

State를 상속받은 클래스를 만든다.

class AppState extends State<App> {
  int counter = 0;

  Widget build(context) {
    return MaterialApp(
      home: Scaffold(
        body: Text('$counter'),
        appBar: AppBar(
          title: Text("Lets see some images!"),
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), //Widget 추가
          onPressed: () {
            // 이벤트 콜백 함수                        
          },
        ),
      ),
    );
  }
}

createState 함수를 StatefullState 클래스에 정의를 한다.

@override
State<StatefulWidget> createState() {
  // TODO: implement createState
  return AppState(); // 전 단계에서 만든 클래스
}

마지막으로 변경될 값을 setState() 함수내에서 정의한다.

여기에선 floating action button 을 클릭시 counter 를 1씩 올리는 작업이 이루어진다.

....
onPressed: () {
  // 이벤트 콜백 함수            
  setState(() {
    counter += 1; // 1씩 올리고 있다.
  });
},
....          

Flutter 카카오톡 오픈 채팅방 바로가기 : https://open.kakao.com/o/gsshoXJ

커스텀 위젯 추가

하나의 파일에 많은 코드를 추가시 복잡해지고 길어지는 단점이 있다. 이럴때 파일로 빼서 분리를 할수 있다.

임포트 방법

내부에 src 폴더를 만들고 그 안에 app.dart 파일을 만들자.

클래스 구조를 만들수 있다.

  1. import 매트리얼
  2. Stateless(StatefulWidget)Widget class 생성
  3. Build 함수 구현
  4. main.dart 에서 src/app.dart 호출
# src/app.dart

import 'package:flutter/material.dart';

class App extends StatelessWidget {
  Widget build(context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text("Lets see some images!"),
        ),
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), //Widget 추가
          onPressed: () {
            // 이벤트 콜백 함수            
          },
        ),
      ),
    );
  }
}
# main.dart

import 'package:flutter/material.dart';
import 'src/app.dart';

void main() {  
  runApp(App());
}

그런데 여기에서 StatelessWidget 과 StatefulWidget 이 나오는데 차이점은 무엇일까?

StatelessWidget의 경우 상태관리가 필요없는 위젯을 뜻한다. 즉 다시말해 내부에서 변하는 값을 가지는 게 없는 경우이다.

StatefulWidget은 반대로 state 관리를 함으로써 값이 변할때 다시 랜더링을 해주는 차이이다.

StatefulWidget은 어떻게 만들어지는 다음 시간에 알아보도록 하겠다.



Scaffold 위젯 추가


Flutter 카카오톡 오픈 채팅방 바로가기 : https://open.kakao.com/o/gsshoXJ


기본적으로 모바일 구조가 상단에 Appbar가 있으며 하단에 바텀시트가 있으며 floating action button이 있다.

그 구조를 기본적으로 지원하는 하나의 위젯이 Scaffold라고 보면 된다.

그럼 하나씩 적용해보자.

import 'package:flutter/material.dart';

void main() {
  var app = MaterialApp(
    home: Scaffold(
      appBar: AppBar(),    // 추가  
    ),
  );

  runApp(app);
}

짜잔~ 이런 타이틀바가 생긴걸 볼수 있다. 이걸 AppBar 라고 한다.

그리고 아래 floating button 을 추가도 아주 쉽게 할수 있다.

import 'package:flutter/material.dart';

void main() {
  var app = MaterialApp(
    home: Scaffold(
      appBar: AppBar(
        title: Text("Lets see some images!"),        
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add), //Widget 추가
        onPressed: () { // 이벤트 콜백 함수
          print('Hi there!');
        },
      ),      
    ),
  );

  runApp(app);
}

추가적으로 메테리얼 아이콘을 변경시

https://material.io/tools/icons 에서 검색해서 Icons.*** 변경하면 된다.


Flutter 시작하기.


Flutter 카카오톡 오픈 채팅방 바로가기 : https://open.kakao.com/o/gsshoXJ


Flutter는 구글에서 나온 모바일 프레임워크입니다. Reactive Native 와 비슷하다고 보면 될것 같습니다. 네이티브 성격을 가진 하이브리드 앱인 셈이죠.

무엇보다 강력한 점은 각 플랫폼(Android, IOS)에 있는 대표 디자인 즉 메테리얼 디자인을 손쉽게 짤수가 있습니다.

하지만 플랫폼별로 다르게 나올수가 있는데 그 이유는 메테리얼도 플랫폼에 맞게 바뀌어서 나올 수 있기 때문입니다.

만약 IOS 에서 특유의 시스템을 이용하고 싶으면 쿠퍼티노 위젯을 사용하면 됩니다.

그 외에도 안드로이드 스튜디어, 비쥬얼 스튜디어 코드등 통합 IDE 도 사용 가능한 점이 큰 메리트입니다.

더 자세한 내용을 원하시면 공식홈페이지를 방문하시길 바랍니다.

구조

헬로우 플러터를 우선 보는게 중요하죠.

각 플랫폼별로 플러터 프로젝트를 생성합니다.

(vscode 에선 view -> 명령 팔렛트 -> flutter new project 선택)

그리고 실행을 해봅니다. ( 터미널에서 flutter run )

중요한건 화면까지 뛰우는 구조인데.

중요한건 4가지를 보시면 됩니다.

  1. import
  2. main 함수 생성
  3. main에 widget 추가
  4. 그 위젯을 화면에 추가

코드로 살펴보겠습니다.

import 'package:flutter/material.dart';

void main() {
  var app = MaterialApp(
    home: Text('Hi there!'),

  );

  runApp(app);
}

그럼 다음과 같이 투박한 화면이 나옵니다. ^^;;

그럼 다음시간에는 좀 더 위젯을 추가하도록 해보겠습니다.

+ Recent posts