'분류 전체보기'에 해당되는 글 143건

텐서플로 라이트 전체보기

텐서플로 라이트는 여기까지.미안.
서버와 연동하는 모델과 연동하지 않고 독립적으로 동작하는 모델까지 모두 구현했으므로
내가 하려고 하는 정도로는 충분하다.
스마트폰과 연동하려고 한다면, 플러터와 함께 보는 것이 좋다.


'카테고리 전체보기' 카테고리의 다른 글

텐서플로 라이트 전체보기  (0) 2019.06.07
플러터 (스마트폰 앱) 전체보기  (0) 2019.06.07

플러터 (스마트폰 앱) 전체보기

미안.
아빠가 목록을 빨리 만들어야 우재가 보기 편할텐데..
많이 늦었네.
티스토리는 한번에 다섯 개씩만 보여주게 되어 있어서 많이 불편해.
지금 하고 있는 프로젝트.. 성공하길 바래!
파이링~~


'카테고리 전체보기' 카테고리의 다른 글

텐서플로 라이트 전체보기  (0) 2019.06.07
플러터 (스마트폰 앱) 전체보기  (0) 2019.06.07

서핑 트립 : 다대포 (2)

대박의 꿈은 사라지고
피로에 지친 육신만 남았다.
다대포에 계속 있을 수는 없고 우리는 트립을 왔기 때문에
송정으로 가기로 했다.
배가 고팠는데, 얘기할 분위기가 아니었다.

송정에 도착하니 난리도 아니다.
파도가 꽤 좋긴 했는데, 라인업에 사람들이 무지하게 늘어서 있었다.
서프홀릭에서 보드를 빌린다.
서프홀릭은 라인업까지는 다대포의 10분의 1도 안된다.
차가 많이 다니지 않는 길 건너면 백사장, 그 앞에 바로 라인업.

로컬 서퍼분들과 재호 선생님이 흥분 했다.
다대포를 만회해야한다는 생각이 너무 보였다.
바다에서는 각자 인생을 가는 거다.
입수. 열심히 즐겼다.

마침 파도가 내가 타기에 적당한 수준이었다.
아침보다 줄었다고 하는데..
사람도 처음보다 빠지고 해서 아주 좋았다.
근데 너무 힘들었다.
나는 한 시간쯤 타고 나왔고 나머지 분들은 조금 더 타고 나왔다.
모두 엄청 상기된 상태였다.
무지하게 많이 탔다고 열변을 토한다.

나는 정상적인 파도는 타지 못했다.
라인업에서 파도가 부서지기 전에 탔어야 했는데
피크가 깨지고 나서 거품이 되었을 때 두세 개 정도 탔다.
피크에서 타는 것과는 천지차이.
그래도 좋았다.

점심을 먹어야 하는데 분위기가 이상하다.
다대포 파도가 살아났단다.
다대포로 다시 갈 것이냐 송정에서 나머지 시간을 보낼 것이냐!
부산의 양끝이라 1시간 걸린다.
저녁에 죽도 갈 때도 그만큼 시간이 더 걸릴 것이다.
유명한 개미집에 가서 얘기하기로 한다.
사실 얘기할 필요도 없다.
결정된 상태였지만 괜히 미안해서 결정을 연기한 것뿐이었다.

캠프생들이 신경 쓰였을 것이다.
나는 전혀 상관없었고
멘탈이 나갈 뻔 했던 그곳을 다시 방문하는 것도 멘탈에 좋을 것 같았다.
여자 캠프생 둘도 좋다고 한다.
그래서 물어봤다.
보드 또 빌려야 하냐고. 우리는 오늘 두 번 빌렸다.
다행스럽게 그냥 빌려주셨다.

다시 다대포.
아까와는 다르게 바람 한점 없고 파도는 말랑말랑하다.
좋은 파도라는 것을 설악과 죽도만 경험한 나도 알 정도였다.
송정보다 훨씬 좋았다.
일단 서퍼가 많이 없어서 라인업에서 겹치지 않고 앉을 수 있었다.

대박, 대박!
라인업의 오른쪽 끝에 있었는데..
파도가 오기 전에 일찌감치 패들링으로 보드를 움직이고
가까이 왔을 때 폭풍 패들링을 해서 파도를 잡았다.
다른 서퍼들이 하는 것처럼!
상상할 수 없이 어설펐겠지만 서핑 인생 첫 번째 성공이었다.

그 후로 몇 개의 파도를 더 잡을 수 있었고
파도 위에 있을 때의 기분도 충분히 느낄 수 있었다.
살이 쪄서 파도가 날 무거워한다고 생각했었다.
문제는 타이밍이었다.
파도타기의 정석이랄까..
지켜야 할 규칙이 있는데, 규칙을 전혀 지키지 않고 있었던 것이었다.
지킨다고 생각한 건 나의 착각.

해피엔딩.
다대포에서 최악의 서핑과 최선의 서핑을 모두 맛봤다.
스스로 파도를 잡을 수 있음을 증명했다.
등대까지의 패들링도 효과를 입증했고
등대 앞에서의 보드 컨트롤,
라인업 한쪽에서의 보드 컨트롤도 입증했다.
보드 날려먹지 않고 비슷한 정도로 기다렸고 잡았다!

죽도에 도착하니 새벽 1시.
차를 타고 오면서
파도를 기다릴 때부터 테이크옵해서 일어날 때까지의 상황을
여러 번에 걸쳐 이미지 트레이닝했다.
예전과는 다르게 테이크옵할 때의 중요한 점, 보드의 각도 등이 상상된다.

다음엔 만리포를 말씀하신다.
월요일에 역대급 파도가 만리포에 들어와서 모두를 흥분시켰다.
대한민국에서 볼 수 없는 수준의 파도였다.

난 아무 곳이나 오케이!
재호 선생님이 아니어도 로컬 서퍼가 없어도
서핑은 그냥 재밌다. 딴 동네 가서 하면 더 재밌다!



'서핑일기' 카테고리의 다른 글

서핑 트립 : 다대포 (2)  (0) 2019.05.29
서핑 트립 : 다대포 (1)  (0) 2019.05.29
파도를 잡기 위한 기술 3가지  (0) 2019.05.14
첫 번째 서핑 강습, 그 다음 날  (0) 2019.04.28
첫 번째 서핑 강습  (0) 2019.04.27

서핑 트립 : 다대포 (1)

초밥을 벗어나기 전에
서핑 중에 느꼈던 어려웠던 점을 정리하려고 했는데
놀기도 해야 하고 공부도 해야 하고 돈도 벌어야 해서..

할 수 없다.
머리에서 사라지기 전에 첫 번째 서핑 여행을 정리하는 것이 맞다.
5월 마지막 주 월화수 3일의 시간이 생겼다.
수요일은 어떻게 될지 몰랐지만
월요일과 화요일 이틀은 망고서프에서 보내려고 했다.
결국 수요일은 집에 왔고
도서관에 나와 밀렸던 뒷정리 하고, 지금 이 글을 쓰고 있다.

토요일인가 일요일인가..
재호 선생님께서 트립 간다고 전화를 주셨다.
망고서프는 월요일과 화요일 이틀 쉰다고.
다대포 갈건데 같이 갈거냐고.

월요일 오전에 도착해서 늘 하던대로 등대까지 패들링.
(물론 나하고는 그다지 상관없을 수 있지만)
저녁 전에는 파도가 꽤 좋아서
보드 컨트롤과 실제 파도 잡는 연습을 했다.
참.. 뭐라고 해야 할지..
시도는 무지하게 했고 잡지는 못했고.
그래도 잡은 것도 아니고
잡지 못했다고 얘기하기는 싫은 상황이 몇 번은 있었다.
중요한 점은 내가 라인업과 같은 라인에 있었다는 점이다.
아직까지는 라인업에 있으면 주요 길목을 막아버리는 길막이다.

참, 일요일 배드민턴 대회가 있어
기분 좋게 승급을 하고 점심 먹고 나와서 발목을 접질렀다.
다대포를 가야 하는데 구경만 해야 하는 상황.
발목 나갈 각오를 하고 바다에 들어간 상황이었는데
놀랍게도 바다에서 나오니 발목이 거의 아프지 않았다.
발목을 쓸 일이 없어 시도하긴 했지만 놀라웠다.

어찌 됐든
저녁 7시. 다대포로 출발.
서퍼라면 누구나 만나보고 교류하길 원하는
죽도 로컬 서퍼 세 분과 함께 였다.
여기에 재호 선생님, 나.
같이 캠프에 참가했던 두 사람은 다대포에서 합류. 총 7명이었다.



열심히 달려 자정이 되기 전에 도착해서
늦은 저녁을 거하게 먹고
모두가 하나 된다는 원모텔에 숙소를 잡았다.
참고로 다대포나 송정은 접근성이 너무 좋아서 게스트하우스가 없다.
원모델에서도 맥주 몇 캔을 마시고 나니 새벽 2시.
재호 선생님과 남자 로컬 서퍼 두 분은 3시까지 담소.

새벽 6시에 기상해서 힐링서프로 갔다.
나를 포함한 캠프생들은 그 시간에 보드를 빌릴 수 있었다.
차트가 잘 나온 날에는
새벽부터 손님이 많다고 했다.
굳이 죽도에서 다대포까지 왜 왔겠는가!
그 놈의 차트가 대박 파도 터진다고 해서 온 것 아니겠는가!



7시가 조금 못 되어서 입수.
하얀 거품이 엄청 길게 퍼지는 모습이 장관이었다.
죽도와 달리 힐링서프에서 백사장까지
4차선 도로를 건너고 공영 주차장을 지나
시민들의 휴식처인 공원까지 지나고
매우 넓은 백사장을 지나 그 만큼의 갯벌을 지나야
라인업 초입에 도착할 수 있었다.
간만의 차가 심한 곳이었다.



헐.. 바람이 불고 조류가 많이 셌다.
그래도 깊지 않아 힘들지만 파도를 뚫고 라인업까지 갔다.
등대까지 다녔던 패들링이 효과를 봤다.

그런데 문제가 생겼다.
임대한 스펀지 보드가 그동안 타던 것하고 너무 달라
컨트롤을 전혀 할 수가 없었다.
라인업까지 나갈 때도 좌우로 흔들리는 것은 기본이고
패들링하다 미끄러지는 일까지 생겼다.
라인업에서 보드에 앉아 파도를 기다려야 하는데
앉아 있을 수가 없었다. 바람 때문에 보드 방향조차 돌릴 수가 없었다.

보드에 앉았다 넘어졌다를 몇 번 반복하고 나니까
백사장 왼쪽에 있는 산책로가 있는 갯바위에 가까이 가 있었다.
서퍼 몇이 있기는 했지만
선생님을 찾아 반대편으로 겁나게 패들링을 했다.
15분쯤 하지 않았을까?
느낌이 이상했다.
좀 가깝게 간 것 같기는 한데
가까워졌다고 얘기하기도 뭐한 거리만큼 이동했다.
잠깐 쉬면 원래 자리로 돌아간다.
헐.. 살짝 겁 났다.

그만큼의 패들링을 다시 했고
그제서야 선생님을 만날 수 있었다.
너무 안심했지만
실제로는 내가 선생님쪽으로 간게 아니고
재호 선생님이 조류에 밀려 내쪽으로 온 것이었다.
함께 처음 자리로 돌아가려고 했고
선생님을 따라 갈 수 있으리라고 여겼지만
선생님만 갔고 나는 남았다.

그래도 처음 라인업이라고 생각했던 곳으로 계속 패들링.
주변에 있던 서퍼들은 어느샌가 보이지 않는다.
내가 갔는지 그들이 왔는지 모르는 상황, 캠프생 둘이 함께 있다.
너무 반가워서 인사를 하고 
이때까지도 나가야 한다는 생각을 못했다.
캠프생들이 여기서 파도를 잡고 있었다,라고 생각을 했었다.
두 사람이 바닷가쪽으로 이동하기에
파도라서 하나 잡아보려는 시도는 해봐야 할 것 같아서
몇 개 잡아보는 척 했고 다시 혼자가 됐다.

나가기 위해 패들링을 했다.
아까보다 현격하게 이동하는 거리가 짧아졌다.
좀 전에는 짧기는 했지만 이동하는 느낌이 있었는데
움직이긴 하는데.. 움직이는 것 같지 않은 느낌이랄까..

패들링을 얼마나 했는지도 모르겠다.
백사장으로 나가는 걸 포기하고
숲이 있는 갯바위로 방향을 바꿨다.
앞서 몇 명이 그쪽으로 나가는 것을 봤었고 바보 같다는 생각을 했었다.
이런 조류와 바람이 산책로가 있는 곳으로 가지 않고
바다쪽으로 나를 밀어붙였다.
겁나게 패들링을 해도 갯바위하고 점점 멀어지는 느낌이다.
패들링하는 오른쪽은 망망대핸데..

삐뽀삐뽀.
어디선가 사이렌 소리가 울린다.
해양 경찰이 출동했다. 배가 두 대나 왔다.
뭐라고 하는데.. 잘 안 들렸다.
자존심이 남아서 그랬을까, 갯바위쪽으로 가는 중이라고 했다.
가까이 오지 않은 상태에서 손짓으로 오라고 한다.
마지 못해 갔다.
조류와 바람을 등에 업으니까 꽤 먼 거리였는데 금방 도착했다.
해양 경찰 한 분이 바다에 뛰어들어 쏘세지 하나를 건네주고 잡이라 한다.
배까지의 거리는 얼마 안 되는데.. 잡았다.
헤엄치면서 나를 끌어가려 하는데 앞으로 가질 않는다.
내가 보드 위에서 물장구를 쳐서 도와드렸다.

나는 첫 번째 구조자였다.
가장 먼저, 가장 멀리 떠내려간 서퍼였던 것이다.
총 9명이 구조되었고 8명은 두 번째 배로 구조되었다.
그 중에는 로컬 서퍼도 한 분 계셨다.
나하고 비슷하거나 더 있어 보이는 분이었는데
다대포에서 활동하시는 로컬 서퍼로써 화가 많이 나보였다.

해양 경찰이 모는 배로 먼 바다를 여행하니 좋았다.
나 말고 모두 기분이 나빠 보였다.
망고서프의 이름을 알리지 않기 위해 서울에서 왔다고 했다.
배에 계신 분들이 친절하게 해주셨고
왜 이 날씨에 바다에 나갔냐고 뭐라 하지 않았다.

다대포항에 도착했는데 해수욕장까지는 직접 가라고 한다.
헐.
우리 모두는 슈트를 입었고 신발도 전화도 없다.
있는 거라곤 거추장스러운 보드뿐!
경찰이 힐링서프에 전화를 해줬고 선생님께서 오셨다.
너무 놀래서 얼굴이 하애지셨다.
회센터 아주머니와 대화 중이던 나는
너무 즐겁게 웃고 하는 바람에 혼났다.
캠프생하고 트립 왔는데.. 그 심정 오죽 했겠는가!

로컬 서퍼분들도 그렇고 선생님도 그렇고
간신히 빠져나왔다고 했다.
평생 이런 조류와 바람은 처음이었다고.
우리가 구조된 이후로 다대포는 임시 폐쇄되었다.

여기서 놀라운 점 하나.
두 시간을 패들링하고 조류와 바람에 시달렸음에도
패들링할 수 있는 힘이 남아 있었다.
패들링은 어꺠로 하는 게 아니라 등으로 하는 거다.

'서핑일기' 카테고리의 다른 글

서핑 트립 : 다대포 (2)  (0) 2019.05.29
서핑 트립 : 다대포 (1)  (0) 2019.05.29
파도를 잡기 위한 기술 3가지  (0) 2019.05.14
첫 번째 서핑 강습, 그 다음 날  (0) 2019.04.28
첫 번째 서핑 강습  (0) 2019.04.27

14. 플러터 : 개와 고양이 사진 분류 (케라스) (5)

이번 프로젝트는 너무 힘들었기에
문제를 해결하는데 사용했던 몇 가지 함수를 정리한다.
훨씬 더 많은 코드를 만들어서 검증했지만
완성하고 나서 보니 대부분은 의미 없었던 함수였다.

첫 번째는 지정한 사진에 대해 정확도를 보여주는 코드다.
에뮬레이터에서 시험해 보면
결과가 너무 말도 안되게 나와서 어떤 문제인지 먼저 찾아야 했다.
데스크탑에서도 똑같이 말도 안되게 나오는지 확인이 필요했다.

나의 실수로 서로 다른 파일을 사용했던 관계로
데스크탑에서는 결과가 너무 잘 나왔다.
개와 고양이 모델 파일을 여러 개 만드는 과정에서 발생한
말도 안되는 실수로 일 주일은 날려 먹었다.

어쨌든 데스크탑과 스마트폰에서
동일한 결과가 나온다는 것을 검증하는 것은 중요하다.
물론 변환을 거쳤기 때문에 미세한 오차가 발생하는 것은 감안해야 한다.

scaling 옵션에 따라 결과가 다르게 나오는 점도 확인해야 한다.
True 옵션을 사용하는 것이 맞다.

import tensorflow as tf
from PIL import Image
import numpy as np


def load_image(img_path, scaling):
img = Image.open(img_path)
img = img.resize([150, 150])
img.load()
data = np.float32(img)

# 스케일링 유무에 따라 결과가 달라진다. 하는 것이 좋은 결과를 만든다.
# 학습할 때 스케일링을 적용했기 때문에 여기서도 적용하는 것이 맞다.
if scaling:
data /= 255
return data


def predict_model(model_path, images, scaling):
model = tf.keras.models.load_model(model_path)

stack = []
for img_path in images:
d = load_image(img_path, scaling=scaling)

d = d[np.newaxis] # (150, 150, 3) ==> (1, 150, 150, 3)
stack.append(d)

# (1, 150, 150, 3)으로 구성된 배열 결합 ==> (4, 150, 150, 3)
data = np.concatenate(stack, axis=0)

preds = model.predict(data)
print(preds.reshape(-1))


images = ['cats_and_dogs/small/test/cats/cat.1500.jpg',
'cats_and_dogs/small/test/cats/cat.1501.jpg',
'cats_and_dogs/small/test/dogs/dog.1500.jpg',
'cats_and_dogs/small/test/dogs/dog.1501.jpg']

predict_model('models/cats_and_dogs_small_4.h5', images, scaling=False)
predict_model('models/cats_and_dogs_small_4.h5', images, scaling=True)
# False : [0. 0. 1. 1.]
# True : [0.00184013 0.00273606 0.94292957 0.9738878 ]


앞의 코드보다 더 중요한 것이 있다.
같은 모델이라도 변환을 거쳤기 때문에
그 과정에서 원하지 않던 결과가 나올 수도 있다.

스마트폰에 사용한 모델을 데스크탑에서 사용할 수 있으면 좋지 않을까?
그래서 준비했다.
텐서플로 라이트 모델을 데스크탑에서 사용하는 코드.


def test_tflite_model(tflite_path, images):
interpreter = tf.lite.Interpreter(model_path=tflite_path)
interpreter.allocate_tensors()

# 입력 텐서 정보 : 인덱스를 알아야 데이터를 전달할 수 있다.
input_details = interpreter.get_input_details()
# [{'name': 'conv2d_60_input', 'index': 3, 'shape': array([ 1, 150, 150, 3], dtype=int32),
# 'dtype': <class 'numpy.float32'>, 'quantization': (0.0, 0)}]

# 출력 텐서 정보 : 인덱스를 알아야 결과를 받아올 수 있다.
output_details = interpreter.get_output_details()
# [{'name': 'dense_41/Sigmoid', 'index': 18, 'shape': array([1, 1], dtype=int32),
# 'dtype': <class 'numpy.float32'>, 'quantization': (0.0, 0)}]

result = []
for img_path in images:
input_data = load_image(img_path, scaling=True)
input_data = input_data[np.newaxis]

# 입력 데이터 전달
interpreter.set_tensor(input_details[0]['index'], input_data)
interpreter.invoke()

# 출력 데이터 읽기
output_data = interpreter.get_tensor(output_details[0]['index'])
result.append(output_data)

# 1차원 변환 출력
print(np.reshape(result, -1))


# 텐서플로 라이트 모델 경로와 시험할 사진 경로 전달
test_tflite_model('models/cats_and_dogs.tflite', images)


위의 코드는 내가 만든 코드는 아니고 스택 오버플로우를 참고했다.
여기서는 난수를 사용해서 어떤 데이터에 대해서도 동작하도록 처리하고 있다.
더 범용적인 코드라고 보면 된다.
나도 두고두고 봐야 하니까 코드를 붙여넣어 둔다.

How to import the tensorflow lite interpreter in Python?


def test_tflite_model_by_stack_overflow(tflite_path):
# Load TFLite model and allocate tensors.
interpreter = tf.contrib.lite.Interpreter(model_path=tflite_path)
interpreter.allocate_tensors()

# Get input and output tensors.
input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

# Test model on random input data.
input_shape = input_details[0]['shape']

# 사진 경로 매개 변수는 없다. 난수로 만들면 되니까. 문법적으로 에러 나지 않는 것만 확인하면 된다.
input_data = np.array(np.random.random_sample(input_shape), dtype=np.float32)
interpreter.set_tensor(input_details[0]['index'], input_data)

interpreter.invoke()
output_data = interpreter.get_tensor(output_details[0]['index'])
print(output_data)


13. 플러터 : 개와 고양이 사진 분류 (케라스) (4)

이번 프로젝트의 마지막인 안드로이드 부분만 남았다.


6번
파이썬으로 작업한 딥러닝 모델 파일을 추가한다.
MainActivity.java 파일이 포함된 java 프로젝트와 같은 위치에 assets 폴더를 생성하고
cats_and_dogs.tflite 모델 파일을 붙여넣는다.



7번
gradle 파일을 수정한다.
위의 그림에서 profile 폴더 바로 아래 있는 파일이다.
같은 이름이 두 개 있어서 헷갈리기 딱 좋다.


android {
...

// 추가한 부분
aaptOptions {
noCompress "tflite"
}
}


dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

// 추가한 부분
implementation 'org.tensorflow:tensorflow-lite:+'
}


8번
경로 수신 및 모델 연동 코드를 구현한다.
MainActivity.java 파일을 열고 아래 코드를 붙여넣는다.


package tflite.com.catdog.flutter_catdog;

import android.os.Bundle;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.util.Log;

import java.io.FileInputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

import org.tensorflow.lite.Interpreter;

// 기본적으로 포함되어 있는 모듈
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;

// MethodChannel 관련 모듈 (추가해야 함)
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.common.MethodCall;

public class MainActivity extends FlutterActivity {
// 채널 문자열. 플러터에 정의한 것과 동일해야 한다.
private static final String mChannel = "catdog/predict";

@Override
protected void onCreate(Bundle savedInstanceState) {
// 빌트인 코드
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// 추가한 함수.
new MethodChannel(getFlutterView(), mChannel).setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
// 각각의 메소드를 switch로 분류. 아이폰에서는 else if 사용.
switch (call.method) {
case "predictImage":
// Map<String, String> args = (HashMap) call.arguments;
// String path = args.get("path");
String path = call.argument("path");

final Bitmap bitmapRaw = BitmapFactory.decodeFile(path);

int cx = 150, cy = 150;
Bitmap bitmap = Bitmap.createScaledBitmap(bitmapRaw, cx, cy, false);

int[] pixels = new int[cx * cy];
bitmap.getPixels(pixels, 0, cx, 0, 0, cx, cy);

// 두 가지 방법 모두 동작. 입력 텐서는 자료형을 내부적으로 재해석한다.
// float[][][][] input_img = getInputImage_1(pixels, cx, cy);
ByteBuffer input_img = getInputImage_2(pixels, cx, cy);
// float[] input_img = getInputImage_3(pixels, cx, cy);

Interpreter tf_lite = getTfliteInterpreter("cats_and_dogs.tflite");

float[][] pred = new float[1][1];
tf_lite.run(input_img, pred);

final String predText = String.format("%f", pred[0][0]);
Log.d("예측", predText);
result.success(predText);
break;
default:
result.notImplemented();
}
}
}
);
}

// 입력 이미지와 동일한 형태의 배열 사용
private float[][][][] getInputImage_1(int[] pixels, int cx, int cy) {
float[][][][] input_img = new float[1][cx][cy][3];

int k = 0;
for (int y = 0; y < cy; y++) {
for (int x = 0; x < cx; x++) {
int pixel = pixels[k++]; // ARGB : ff4e2a2a

input_img[0][y][x][0] = ((pixel >> 16) & 0xff) / (float) 255;
input_img[0][y][x][1] = ((pixel >> 8) & 0xff) / (float) 255;
input_img[0][y][x][2] = ((pixel >> 0) & 0xff) / (float) 255;
}
}

return input_img;
}

// 다루기 편한 1차원 배열 사용
private ByteBuffer getInputImage_2(int[] pixels, int cx, int cy) {
ByteBuffer input_img = ByteBuffer.allocateDirect(cx * cy * 3 * 4);
input_img.order(ByteOrder.nativeOrder());

for (int i = 0; i < cx * cy; i++) {
int pixel = pixels[i]; // ARGB : ff4e2a2a

input_img.putFloat(((pixel >> 16) & 0xff) / (float) 255);
input_img.putFloat(((pixel >> 8) & 0xff) / (float) 255);
input_img.putFloat(((pixel >> 0) & 0xff) / (float) 255);
}

return input_img;
}

// 될 것 같지만 되지 않는 코드
private float[] getInputImage_3(int[] pixels, int cx, int cy) {
float[] input_img = new float[cx * cy * 3 * 4];

int k = 0;
for (int i = 0; i < cx * cy; i++) {
int pixel = pixels[i]; // ARGB : ff4e2a2a

input_img[k++] = ((pixel >> 16) & 0xff) / (float) 255;
input_img[k++] = ((pixel >> 8) & 0xff) / (float) 255;
input_img[k++] = ((pixel >> 0) & 0xff) / (float) 255;
}

return input_img;
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


코드를 교체하고 나서 문제가 발생했다면
오른쪽 상단에 있는 Setup SDK 메뉴를 선택해서 해결해야 한다.
아래와 같은 화면이 뜨고 적당한 버전을 선택해서 연결한다.



9번
개와 고양이 사진을 에뮬레이터로 찍기는 어렵다.
안드로이드 폰에 올려서 결과를 볼 수도 있겠지만
여기서는 에뮬레이터로 복사해서 가져다 쓰도록 한다.

[View - Tool Windows - Device File Explorer] 메뉴를 선택한다.
[sdcard - Download] 폴더에서 마우스 오른쪽 버튼을 눌러 업로드를 선택한다.
개와 고양이 폴더에서 각각 2개씩 추가했다.


  


4장의 사진을 업로드하고 나면 아래와 같이 확인할 수 있다.



이제 모든게 끝났다.
완성을 하지 못해서 계속 비공개로 유지했었다.
마지막으로 우재 폰을 사용해서 카메라 메뉴도 확인했다.
모니터에 있는 개와 고양이 사진을 얼마나 잘 분류하던지..
감동이었다.

일요일 12시 20분.
우재는 곤충 박사님이 부탁한 프로젝트를 하고 있고
서진이는 한동안 잊고 살았던 파이썬을 다시 공부하고 있다.
모두 흥미를 느끼고 있어 좋다.
일요일이 일요일이다.
다만 많이 시끄럽다. ^^

12. 플러터 : 개와 고양이 사진 분류 (케라스) (3)

모델 파일은 생성했고
이제 스마트폰에 올릴 코드를 구성하면 된다.
먼저 플러터 코드를 구성하고 다음 글에서 안드로이드 코드를 구현한다.


3번
안드로이드 스튜디오에서 flutter_catdog으로 플러터 프로젝트를 생성한다.
프로젝트 옵션에서는 지금처럼 코틀린 사용은 표시하지 않는다. ^^


4번
pubspec.yaml 파일을 열고 이미지 피커 모듈을 추가한다.
잊지 말고, 상단에 있는 Packages upgrade 메뉴를 눌러 업그레이드를 진행한다.


dependencies:
flutter:
sdk: flutter

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
image_picker:


5번
안드로이드와 연동하는 코드를 구현한다.
사진과 카메라 중에서 선택할 수 있고
선택하거나 촬영한 사진으로부터 경로를 가져와서 안드로이드에 전달한다.


import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; // 이미지 피커

class CatDogNets extends StatefulWidget {
@override
CatDogNetsState createState() => CatDogNetsState();
}

class CatDogNetsState extends State<CatDogNets> {
static const MethodChannel mMethodChannel = MethodChannel('catdog/predict');

String mResult = 'No Photo!'; // 예측 결과를 수신할 문자열
File mPhoto; // 선택 또는 촬영한 사진. ImagePicker 반환값

@override
Widget build(BuildContext context) {
Widget photo = (mPhoto != null) ? Image.file(mPhoto) : Placeholder();

return Material(
child: Column(
children: <Widget>[
Expanded(
child: Center(child: photo),
),
Row(
children: <Widget>[Text(mResult, style: TextStyle(fontSize: 23))],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
Row(
children: <Widget>[
RaisedButton(
child: Text('사진', style: TextStyle(fontSize: 23)),
onPressed: () => onPhoto(ImageSource.gallery),
),
RaisedButton(
child: Text('카메라', style: TextStyle(fontSize: 23)),
onPressed: () => onPhoto(ImageSource.camera),
),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
)
],
mainAxisAlignment: MainAxisAlignment.end,
),
);
}

// 앨범과 카메라 양쪽에서 호출. ImageSource.gallery와 ImageSource.camera 두 가지밖에 없다.
void onPhoto(ImageSource source) async {
mPhoto = await ImagePicker.pickImage(source: source);

try {
var photoPath = <String, String>{'path': mPhoto.path};
String received = await mMethodChannel.invokeMethod('predictImage', photoPath);

// 안드로이드로부터 결과 수신하고, 선택한 사진으로 변경
mResult = received;
setState(() => {});
} on PlatformException {}
}
}

void main() {
runApp(MaterialApp(home: CatDogNets()));
}


11. 플러터 : 개와 고양이 사진 분류 (케라스) (2)

이전 글에서 정리한 순서대로
하나씩 하나씩 진행해 보도록 한다.


첫 번째로 개와 고양이를 구분하는 케라스 모델을 만든다.
이번 글에서 사용하는 코드는
"케라스 창시자에게 배우는 딥러닝"에서 가져왔다.
내가 할 수도 있겠지만
그런 정도의 정확도로 어찌 블로그에 올릴 수 있겠는가!
"케라스 창시자" 정말 좋은 책이니까
아직 보지 않았다면 꼭 구매하도록 하자.


깃헙에 가면 참고로 했던 소스 코드와 설명을 볼 수 있다.
여러 가지 버전이 있지만,
여기서는 정확도가 중요한 것이 아니기 때문에 첫 번째 버전을 사용하도록 한다.

5.2 - 소규모 데이터셋에서 컨브넷 사용하기
5.3 - 사전 훈련된 컨브넷 사용하기


(음.. 앞의 말은 취소다.
정확도가 안 나오는 걸로 했다가 무지막지하게 고생했다.
가장 좋은 버전을 사용하도록 하겠다.)


학습하고 검사할 개와 고양이 사진이 필요하다.
깃헙에 있는 데이터셋은
2천개의 사진을 같은 폴더에 두었고
이들을 3개의 폴더로 구분해서 저장하는 것부터 시작한다.
굳이 따라할 필요가 없어서
해당 파일을 각각의 폴더에 나누어서 저장한 압축 파일을 사용한다.
압축을 풀어서 파이썬 소스 파일이 있는 폴더로 붙여넣는다.

실패.
파일을 첨부하려고 했더니 용량 제한이 10mb.
압축 파일은 90mb.
할 수 없다. 직접 폴더를 만들고 파일을 복사해야겠다.

깃헙 프로젝트를 다운로드 받는다.
datasets 안에 들어가면 cats_and_dogs/train 폴더가 보인다.

  1. train 폴더와 같은 위치에 small 폴더 생성
  2. small 폴더 안에 train, valid, test 폴더 생성
  3. train, valid, test 폴더 각각에 cats와 dogs 폴더 생성
  4. 원본 파일이 있는 train 폴더의 고양이 사진 처음 1,000개를 small/train/cats 폴더로 복사
  5. 원본 파일이 있는 train 폴더의 개 사진 처음 1,000개를 small/train/dogs 폴더로 복사
  6. valid와 test 폴더에는 각각 500개씩 복사
  7. 파이썬 소프 파일이 있는 폴더로 cats_and_dogs 폴더 붙여넣기

폴더 생성 및 복사가 끝나면 아래와 같은 모습이 되어야 한다.
사진이 큼지막한게 보기가 아주 좋다. 시원하다.


여기서부터 이전 글에서 언급했던 순서대로 시작이다.


1번
먼저 모델을 생성해서 파일로 저장하는 코드다.
이번 코드에는 주석을 붙이지 않았다.
프로젝트의 목적이 플러터에서 이미 분류 모델을 구동하는 것이지
모델 자체를 설명하는 것이 아니니까.
설명이 필요하면 앞에서 언급한 깃헙 파일을 보도록 한다.


def save_model(model_path, train_path, valid_path):
# 이미지넷에서 가져온 사전 학습한 가중치는 수정하면 안됨
conv_base = tf.keras.applications.VGG16(weights='imagenet', include_top=False, input_shape=[150, 150, 3])

model = tf.keras.models.Sequential()
model.add(conv_base) # 기존 모델 연결
model.add(tf.keras.layers.Flatten())
model.add(tf.keras.layers.Dense(512, activation='relu'))
model.add(tf.keras.layers.Dropout(rate=0.5))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))

# 전체 동결. 이미지넷에서 가져온 사전 학습한 가중치는 수정하면 안됨
conv_base.trainable = False

model.compile(optimizer=tf.keras.optimizers.RMSprop(lr=0.0001),
loss='binary_crossentropy',
metrics=['acc'])

# 학습 데이터 증강
train_img_generator = tf.keras.preprocessing.image.ImageDataGenerator(
rescale=1/255,
rotation_range=20,
width_shift_range=0.1,
height_shift_range=0.1,
shear_range=0.1,
zoom_range=0.1,
horizontal_flip=True)
# 검증 제너레이터는 이미지 증강하면 안됨.
valid_img_generator = tf.keras.preprocessing.image.ImageDataGenerator(rescale=1/255)

batch_size = 32
train_generator = train_img_generator.flow_from_directory(
train_path,
target_size=[150, 150],
batch_size=batch_size,
class_mode='binary')
valid_generator = valid_img_generator.flow_from_directory(
valid_path,
target_size=[150, 150],
batch_size=batch_size,
class_mode='binary')

# 20회 에포크만큼 학습
model.fit_generator(train_generator,
steps_per_epoch=1000 // batch_size,
epochs=20,
validation_data=valid_generator,
validation_steps=50)

# hdf5 형식 : Hierarchical Data Format version 5
model.save(model_path)


model_path = 'models/cats_and_dogs_small.h5'
save_model(model_path, 'cats_and_dogs/small/train', 'cats_and_dogs/small/valid')


데스크탑을 우재와 서진이가 사용하고 있는 관계로
맥북에서 돌리고 있는데.. 4시간은 걸리는 것 같다.
그래서, 내가 만든 모델 파일을 첨부하려고 했다.
10mb 용량 제한에 걸렸다.
우재야 미안하다. 직접 만들어야겠다.


모델을 저장했다면 잘 동작하는지 확인할 차례다.
모델을 로딩해서 정확도를 확인해 본다.


def load_model(model_path, test_path):
model = tf.keras.models.load_model(model_path)

generator = tf.keras.preprocessing.image.ImageDataGenerator(rescale=1/255)

test_gen = generator.flow_from_directory(test_path,
target_size=[150, 150],
batch_size=500,
class_mode='binary')

# 첫 번째 배치 읽기. batch_size가 500이니까 500개 가져옴
x, y = next(test_gen)
print('acc :', model.evaluate(x, y))


load_model(model_path, 'cats_and_dogs/small/test')


아쉽지만, 정확도는 그리 잘나오는 편은 아니다.
여러 가지 버전 중에서 첫 번째 버전을 선택했으니까.
손실 값은 0.58, 정확도는 72.8% 나왔다.

acc : [0.5835418028831482, 0.728]


2번
마지막으로 저장한 모델 파일을 텐서플로 라이트 버전으로 변환한다.
변환은 늘 그렇듯이 이전 글에 나왔던 코드를 붙여넣어서 사용한다.


# 저장한 파일로부터 모델 변환 후 다시 저장
def convert_model(model_path, tflite_path):
converter = tf.lite.TFLiteConverter.from_keras_model_file(model_path)
flat_data = converter.convert()

with open(tflite_path, 'wb') as f:
f.write(flat_data)


convert_model('models/cats_and_dogs_small.h5', 'models/cats_and_dogs.tflite')


이걸로 파이썬 기반으로 작업할 내용은 모두 끝났고
여기서 만든 모델 파일을 안드로이드에서 사용할 것이다.

10. 플러터 : 개와 고양이 사진 분류 (케라스) (1)

여기까지만 하고 그만했으면 좋겠다.
여러 가지를 하려다 보니
매번 할 때마다 새롭게 느껴지는 부분들이 힘들다.


어쨌든 마지막으로
딥러닝으로 개와 고양이를 분류하는 모델을 만들고
플러터를 사용해서 스마트폰에서 직접 분류를 해보려고 한다.
동물을 사랑하느냐고 물으면
당연히 그렇다고 대답하겠지만
그것보다도 기존 예제들이 너무 복잡해서
이미지를 분류할 수 있는 가장 단순한 예제를 만들고 싶었다.

첫 번째 사진은 처음 실행했을 때,
두 번째 사진은 [사진] 메뉴를 선택했을 때를 보여준다.
에뮬레이터에서 사용할 사진은 테스트 폴더로부터 두 장씩 복사했다.

세 번째 사진은 개를 선택했을 때 94.03%,
네 번째 사진은 고양이를 선택했을 때 0.15% 확률로 개라는 것을 보여준다.
개/고양이 모델 학습이 잘 되었음을 알 수 있다.
출력 값은 시그모이드를 통과한 값이고 1은 개, 0은 고양이를 가리킨다.



딥러닝 모델은 안드로이드와 아이폰에서 직접 다루기 때문에
플러터는 앨범에서 가져오거나 카메라로 찍은 이미지를 전달하는 역할만 한다.
전체 구현에서는 파이썬, 플러터, 안드로이드 코드가 필요하고
아이폰은 구현하지 않으려고 한다.
이전에 했던 아이폰 코드를 안드로이드와 비교하면서 따라 하면 되니까.

여러 유형의 코드가 섞여 있어서
이번 글을 포함해서 다섯 번에 걸쳐 올린다.
아래 나열한 순서를 따라 차례대로 해보자.
참고로 마지막 글은
이번 프로젝트를 진행하면서 디버깅을 하기 위한 용도로 만든 코드를 넣었다.

이번 글 만드는데.. 2주 걸렸다.
내가 실수를 한 부분이 있긴 했지만..
그래도 너무 오래 걸렸다.
우재는 실수하고 찾지 못하는 아빠를 닮지 말고.
안 된다고 화내지 말고.
모든 건 자신의 잘못이라는 걸 겸허하게 인정하는 사람이 되라.
이제 한동안 화내지 않을께. ^^


# 딥러닝

1. 모델 구현 (케라스)
2. 모델을 텐서플로 라이트로 변환


# 플러터

3. flutter_catdog 프로젝트 생성
4. pubspce.yaml 파일 수정
5. 이미지 경로를 스마트폰에 전달하고 수신하는 코드 구현


# 안드로이드
# 플러터 프로젝트에 포함된 안드로이드 프로젝트 사용

6. 텐서플로 라이트 모델 파일 추가
7. gradle 파일 수정
8. 경로 수신 및 사진을 모델에 전달하고 결과 수신
9. 개와 고양이 사진 에뮬레이터에 복사

파도를 잡기 위한 기술 3가지

서핑 2년차.
그나마 1년은 혼자 탄다고 나쁜 습관만 들었을지도 모르겠다.
가르쳐 주는 것을 해야 하는 것은 알겠는데
잘 되지 않는다.
이 글을 보는 당신처럼. ^^

아직 멀었지만, 잘 할 거라는 걸 안다.
지금까지 늘 그랬왔으니까.
다만 굉장히 더디게 올 수 있겠다는 느낌이 솔솔 들기 시작하고 있다.
나같은 경우는
강의를 오래 했기 때문에
남들이 궁금해 하지 않는 것들도 궁금하고
궁금증을 해결하지 않으면 뒤로 넘어가지 못한다.

서핑하는 얘기야
파도가 좋았다, 좋지 않았다
파도 위에 올라가니 기분이 좋았다, 정도의 단편적인 얘기일 뿐이고
나름 너무 부족한 이 시점에서 궁금했던 것들을 정리하려고 한다.


파도를 잡기 위한 기본

  1. 패들링
  2. 보드 컨트롤
  3. 파도 보는 눈


파도가 일어나는 라인업까지 가려면
정도의 차이는 있지만 파도 몇 개는 넘어야 한다.
백사장에서 보면 분명 한 개만 넘으면 될 것 같았겠지만
실제 파도를 뚫고 라인업까지 가려고 하면
파도는 무한하게 반복한다.
패들링을 잘 하면 넘어야 하는 파도 개수가 줄어들고
큰 파도가 와도 슬기롭게 헤쳐나갈 수 있게 된다.

라인업까지 가는데 성공했다면
파도가 올 때까지 기다려야 한다.
문제는 파도가 치는데
어떻게 파도를 기다릴 것이냐,하는 건데..
이때 보드를 컨트롤할 수 있는 기술이 필요하다.
라인업에 있는 서퍼들은 너무 자연스럽지만
실제 파도가 치는 바다에서 보드 위에 편하게 앉아있는 것은 쉽지 않다.

파도가 온다고 치자.
어떻게 알 수 있느냐 하면, 파도보다 옆 사람을 보는 것이 빠르다.
옆 사람이 엎어질 때 따라 엎어지면 된다.

앉아있을 때 어느 방향을 보고 앉아야 할까?
절반은 파도를 기준으로 왼쪽 또는 오른쪽 90도 방향으로 앉는다.
이렇게 앉으면 파도와 보드가 수평이 되고
90도만 돌리면 파도를 수직으로 받을 수 있다.
그러나, 절반의 절반은 처음부터 파도에 수직으로 앉는다.
장점은 보드를 돌리지 않아도 되는 것이고
단점은 목에 무리가 간다는 정도.
너무 한쪽으로만 꺽지 않는 생각하는 서퍼가 되어야 한다.
절반의 절반, 즉 고수들은 파도를 정면으로 보고 앉는다.
왔다 싶으면 180도 돌려서 파도를 잡는다.

현재의 나는 어떤 방향에 앉아있건
파도가 오면 바다에 빠진다. 풍덩!
평상시에는 조절이 가능한데
파도만 보면 마음이 급해지면서 컨트롤이 전혀 안 된다.
그래서 라인업에 가지 않는다. ^^

보드를 돌리는 것이 어려운 이유는
앉았을 때의 보드 각도 때문이다.
보드의 중간에 앉으면 전혀 돌릴 수가 없다.
뒤쪽에 앉을 수록 잘 돌아가지만 위험은 증가한다.
잘 앉아있다가도 돌리려고 하면 뒤로 넘어지기 일쑤다.
부단한 연습을 통해
보드를 높게 세운 각도에서도 돌릴 수 있는 서퍼가 되어야 하는데..
언제?

라인업에 처음 나간 서퍼, 초밥이라고 부른다.
초밥들은 그래서 일찌감치 파도에 수직으로 누워서 기다린다.
올 것 같은 파도가 중간에 사라지기도 하지만
굉장히 확률이 높고 안정적으로 파도를 잡을 수 있다.
난, 아직 초밥도 아니다. 좋아하는 음식일 뿐이다.

마지막으로 파도 보는 눈.
이건 아직 모른다.
파도 꼭대기를 피크라고 부르는데,
피크가 되기 전에 타면 패들링을 많이 하지 않아도 파도를 잡을 수 있다.
나는 폭풍 패들링을 해도 못 잡는다.

오늘은 무릎 파도에서 1개 탔다.
멈춰 있는 상태에서
파도가 오는 것을 보고 패들링을 했고
파도가 가까이 왔을 때, 즉 피크 앞에서 폭풍 패들링을 해서
그 파도 위에 올라탔고 상당한 시간동안 있을 수 있었다.
정식으로 잡은 첫 번째 파도였다.

다만 파도가 올 때 패들링을 했고
피크 앞에서 폭풍 패들링을 했다고 했지만
상상일 수 있다.
그냥 운이 좋아서
왜 됐는지도 모르는 상태에서 올라탄 것일 수도 있다.
슬프긴 하지만, 현실이다.

나는 지금 양양 망고서프에 있다.
빨리 플러터 마지막 작업해야 하는데
며칠째 해결하지 못하고 고생하고 있는데
서핑 글을 쓰고 있다.
나는 서퍼가 되려고 하나 보다!

이 글이 망고서퍼를 홍보하는 글은 아니지만
초밥 이전의 나를 보고 싶다면 서두르는 것이 좋겠다.
내일은 파도를 두 개쯤 잡을 거니까.


'서핑일기' 카테고리의 다른 글

서핑 트립 : 다대포 (2)  (0) 2019.05.29
서핑 트립 : 다대포 (1)  (0) 2019.05.29
파도를 잡기 위한 기술 3가지  (0) 2019.05.14
첫 번째 서핑 강습, 그 다음 날  (0) 2019.04.28
첫 번째 서핑 강습  (0) 2019.04.27

첫 번째 서핑 강습, 그 다음 날

지난 해에 탔던 방식이나 시간들이 잘못되지는 않았겠지만
너무 혼자서 하려고 했나,하는 자책감이 살짝 든다.

망고서프의 재호 선생님과 지열 선생님을 모시고
이것저것 얻어듣다 보니
서핑 역시도 알아야 할 이면의 것들이 참 많다.
새로 배운다는 것이 몹시 어렵다.

새벽에는 등대까지 패들링,
오전에는 라인업에 잠시 등장했다가 등대까지 다시 패들링.
오후에는 라인업에서 마지막까지 버텼는데
이게 참.. 미안한 짓이다.

라인업엔 파도를 기다리는 많은 서퍼들이 줄 지어 있다.
나같이 보드에서 자꾸 떨어지는 사람이 있으면
자신의 차례가 됐어도
길막 역할을 하는 나로 인해 파도를 잡지 못한다.
나는 라인업에 나갈 준비가 조금 덜 됐다.
이건 파도를 잡고 말고의 문제라고 볼 수 없다.

얼떨결에 파도를 두 개나 잡았다.
파도는 덜 하지만 마음이 편하기에
라인업에서 가장 끝에서 기다리고 있었는데
옆의 사람이 준비를 하길래
덩달아 준비를 했고
보드 위에 올라갈 상황은 아니었는데
한참을 달려도 파도가 밀어주기에 슬그머니 보드 위에 설 수 있었다.

패들링을 못해도
상체를 들지 못해도
나에게는 파도라는 친구가 있음을 알았다.

오늘 하루는 정말 길었고
이번 주말, 망고서프에서의 공부는 말하기 부끄러울 만큼 했다.
일과 취미를 병행하기 위해
빨리 파도를 잡을 수 있도록 더 열심히 해야겠다.

썬블록을 바르지 않아 어느 새 빨개진 얼굴.

'서핑일기' 카테고리의 다른 글

서핑 트립 : 다대포 (2)  (0) 2019.05.29
서핑 트립 : 다대포 (1)  (0) 2019.05.29
파도를 잡기 위한 기술 3가지  (0) 2019.05.14
첫 번째 서핑 강습, 그 다음 날  (0) 2019.04.28
첫 번째 서핑 강습  (0) 2019.04.27

첫 번째 서핑 강습

서핑은 2018년에 우재, 서진이와 함께 시작했다.
이제 2년차로 접어들었지만
혼자서만 열심히 했기에 실력은 보잘 것 없다.
올해 처음으로 서핑을 하러 왔다가
마음 맞는 사람들을 만나
조금은 즉흥적으로 여름 되기 전에 파도를 잡고 싶은 욕심에
함께 한 달 동안 강습을 받기로 했다.

오늘은 첫 번째 날이다.
토요일과 일요일, 이틀 동안 진행되고
월요일에는 대전으로 간다.
다음 주말이 오면 다시 이곳에 올 것이고.

망고서프.
본격적인 서핑 시즌이 되면 생망고 주스를 판다.
그래서 망고서프다.
단순하다.

바다에 들어가기 전에는
약간이지만 두려운 마음이 든다.
잘 하지 못해 육체적으로 힘들다는 것을 머리가 기억한다.

지금 시간은 오전 9시.
바다는 24시간.
1시간 정도 작업을 했고 30분쯤 독서를 했고.
그냥 책을 읽게 되는 이곳은 망고서프!


사진이 흔들렸나 보다.
바다를 아래 놓고 하늘을 위에 많이 놓으면
어떤 사진이어도 멋있게 보인다는 요령을 터득했다.
급한 마음에 한 장 찍어왔는데..


여기서부터 오후다.
오전 강습을 마치고 다슬기 해장국을 먹고 글을 쓴다.
여전히 햇볕이 좋다.

지난 한 해는 혼자서 탔던지라
기존 팀에 합류하지 못하고 초보자들과 기초 강습을 다시 받았다.
서운하지 않다.
강사가 되어 사람들을 가르치다 보니
이런 건 정말 별거 아니었다.
모르면 배우면 되고 지금 할 수 있는 것을 하면 된다는 것을 깨달았다.

2층에 가면 공포의 5번 방이 있다.
강습 팀은 오후 연습하러 바다로 나갔고
나는 기초가 부족한 상태에서 나가는 것이 의미 없기에
5번 방으로 간다.

5번 방에는 침대들과 달랑 보드가 한 장 깔려있다.
이곳에서 지상 훈련을 한다.
땅에서도 안되는 것이 흔들리는 물 위에서 되겠는가!
이 부분이 마음에 든다.
틀림없이 나는 이곳에서 많은 시간을 보낼 것이다.
그리고 어느 날 바다로 나갈 것이다.
그 날을 꿈꾸며..
이제 공부하러 간다.

'서핑일기' 카테고리의 다른 글

서핑 트립 : 다대포 (2)  (0) 2019.05.29
서핑 트립 : 다대포 (1)  (0) 2019.05.29
파도를 잡기 위한 기술 3가지  (0) 2019.05.14
첫 번째 서핑 강습, 그 다음 날  (0) 2019.04.28
첫 번째 서핑 강습  (0) 2019.04.27

31. 플러터 : 앨범 + 카메라

수업도 하고 창업 준비도 하고..
그리고 놀기도 해야 하고..
하루가 길지 않음을 새삼 느끼게 된다.

플러터를 사용하는 기본적인 코드조차 머리 속에 남아있지 않음을 느끼며
이건 빨리 우재 시켜야겠다는 생각이 든다.
딥러닝 모델을 플러터에 얹으려니
카메라 사용을 먼저 처리하지 않으면 아무 것도 할 수 없어서
어쩔 수 없이 시간을 내야 했다.
머리가 너무 아프다.

앱을 구동하면 나타나는 화면.
사진도 카메라도 없기 때문에 'EMPTY' 문자열을 표시했다.
갤러리에서 사진을 선택해서 가져오면 오른쪽과 같은 모양이 된다.
이때 안드로이드 에뮬레이터 또는 아이폰 시뮬레이터에서 가져와야 하기 때문에
갤러리 또는 앨범에 사진이 들어있어야 한다.
에뮬레이터에 사진을 추가하는 방법은 다른 사이트를 참고하도록 한다.

카메라 버튼을 선택하면 촬영 모드로 들어가고
촬영한 이후에는 갤러리에서 가져오는 것과 동일하다.
실물이 훨씬 낫다.

잘 봤겠지?
이제부터 앞에서 본 것처럼 훌륭한 앱을 직접 만들어 보자.
약간 업된 것 같은 이유는
앱을 코딩했을 때는 도서관이었지만
이 글을 쓰는 곳은 바닷가이기 때문이다.
서핑하러 인구해변에 왔는데
역스웰이래나 뭐래나.. 그냥 파도가 없을 뿐이다.

프로젝트를 생성하고
pubspec.yaml 파일을 열어서 image_picker 모듈을 가져온다.
dependencies 영역 마지막에 'image_picker:'라고 쓴다.
버전을 생략하면 최신 버전을 가져온다는 뜻이다.

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2

  # 최신 버전 사용하도록 버전 명시하지 않음
  image_picker:

 

main.dart 파일을 작성한다.
앞서 봤던 것처럼 처음에 문자열을 표시했다가
사진을 선택하면 해당 사진으로 바꾼다.

우리가 할건 없고 ImagePicker 라이브러리에
갤러리(ImageSource.gallery) 또는 카메라(ImageSource.camera)를 전달하기만 하면 된다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';    // 앨범과 카메라 모두에 대해 작동


class ImageAndCamera extends StatefulWidget {
    @override
    ImageAndCameraState createState() => ImageAndCameraState();
}


class ImageAndCameraState extends State<ImageAndCamera> {
    // 파일 경로 문자열은 카메라에서는 에러 발생했다. image_picker 모듈에서 File 객체 반환.
    File mPhoto;
    
    @override
    Widget build(BuildContext context) {
        Widget photo = (mPhoto != null) ? Image.file(mPhoto) : Text('EMPTY');

        return Container(
            child: Column(
                children: <Widget>[
                    // 버튼을 제외한 영역의 가운데 출력
                    Expanded(
                        child: Center(child: photo),
                    ),
                    Row(
                        children: <Widget>[
                            RaisedButton(
                                child: Text('앨범'),
                                onPressed: () => onPhoto(ImageSource.gallery),  // 앨범에서 선택
                            ),
                            RaisedButton(
                                child: Text('카메라'),
                                onPressed: () => onPhoto(ImageSource.camera),   // 사진 찍기
                            ),
                        ],
                        mainAxisAlignment: MainAxisAlignment.center,
                    ),
                ],
                // 화면 하단에 배치
                mainAxisAlignment: MainAxisAlignment.end,
            ),
        );
    }

    // 앨범과 카메라 양쪽에서 호출. ImageSource.gallery와 ImageSource.camera 두 가지밖에 없다.
    void onPhoto(ImageSource source) async {
        // await 키워드 때문에 setState 안에서 호출할 수 없다.
        // pickImage 함수 외에 pickVideo 함수가 더 있다.
        File f = await ImagePicker.pickImage(source: source);
        setState(() => mPhoto = f);
    }
}

void main() {
    runApp(MaterialApp(
        home: MaterialApp(
            title: '앨범 + 카메라',
            home: Scaffold(
                appBar: AppBar(title: Text('앨범 + 카메라'),),
                body: ImageAndCamera(),
            )
        )
    ));
}

30. 플러터 : 서버/클라이언트 연동 (5)

http 프로토콜을 다루는 방식에는 여러 가지가 있지만
get과 post 정도만 사용해도 왠만한 것은 처리할 수 있다.
지금까지는 get 방식을 사용해서 서버로부터 데이터를 수신하기만 했다.
get 방식은 가져오기만 하고 보낼 수는 없다.

이번 글에서는 post 방식을 통해
서버로 데이터를 전송하고 결과를 수신하는 코드를 만든다.

상단 왼쪽의 위젯은 사용자 입력을 받는 TextField이고
상단 오른쪽의 위젯은 덧셈과 곱셈 연산의 결과를 보여주는 Text 위젯이다.
서버로 보내는 데이터가 너무 단순해서 실망할 수도 있겠지만
이번 코드를 조금만 확장하면
딥러닝 모델에서 예측해야 하는 데이터를 전송하기에 부족함이 없다.



서버 코드는 이전에 비해 훨씬 간결하게 처리했다.
덧셈과 곱셈 서비스인 add와 multiply 함수밖에 없지만,
함수 위쪽에 지정된 methods에 POST가 추가되어 있음을 눈여겨 보도록 한다.

클라이언트(플러터 앱)로부터 수신한 데이터는 모두 문자열이기 때문에
계산하기 전에 int 함수로 형 변환을 해야 한다.

참.. IP 주소는 자신의 컴퓨터 주소를 쓰는 것도 잊지 말자.
서버 주소가 계속해서 바뀐다.
도서관에서 작업하다가 집에 와서도 계속이다.
어제는 대전에 있었고 토요일 아침인 지금은 강원도 망고서프에 있다.
예제를 구동할 때마다 번거롭다.


from flask import Flask, request

app = Flask(__name__)


@app.route('/add', methods=['POST'])
def add():
left = request.form['left']
rite = request.form['rite']

return str(int(left) + int(rite))


@app.route('/multiply', methods=['POST'])
def multiply():
left = request.form['left']
rite = request.form['rite']

return str(int(left) * int(rite))


if __name__ == '__main__':
app.run(host='192.168.0.125', debug=True)


플러터 앱을 만드고 나서
pubspec.yaml 파일에 http 모듈을 추가하도록 한다.
모든 설명은 코드에 직접 달았으니 코드를 꼼꼼하게 살펴보도록 한다.


import 'dart:async';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

class MyApp extends StatefulWidget {
@override
State createState() => MyAppState();
}

// 덧셈/곱셈 상수 정의
enum DataKind { NONE, ADD, MULTIPLY }

// 서버가 동작하는 컴퓨터의 IP 주소. 192는 사설 IP, 5000은 플라스크 기본 포트.
final gServerIp = 'http://192.168.0.125:5000/';

class MyAppState extends State<MyApp> {
DataKind mKind = DataKind.NONE;

String mResult = '0'; // 서버로부터 수신한 덧셈 또는 곱셈 결과
String mLeftText, mRiteText; // 사용자가 입력한 연산의 왼쪽과 오른쪽 항의 값

// post 동작의 결과를 수신할 비동기 함수
// 연산할 데이터를 전달(post)해야 하기 때문에 멤버로 만들어야 했다.
Future<String> postReply() async {
if(mLeftText == null || mRiteText == null)
return '';

// 문자열 이름은 서버에 정의된 add와 multiply 서비스
var addr = gServerIp + ((mKind == DataKind.ADD) ? 'add' : 'multiply');
var response = await http.post(addr, body: {'left': mLeftText, 'rite': mRiteText});

// 200 ok. 정상 동작임을 알려준다.
if (response.statusCode == 200)
return response.body;

// 데이터 수신을 하지 못했다고 예외를 일으키는 것은 틀렸다.
// 여기서는 코드를 간단하게 처리하기 위해.
throw Exception('데이터 수신 실패!');
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Expanded(
child: Padding(
// TextField 위젯은 사용자 입력을 받는다.
// Expanded 또는 Flexible 등의 위젯의 자식이어야 한다.
child: TextField(
textAlign: TextAlign.center,
style: TextStyle(fontSize: 25),
keyboardType: TextInputType.number, // 숫자만 입력
onChanged: (text) => mLeftText = text, // 입력할 때마다 호출
),
padding: EdgeInsets.all(10.0),
),
),
Expanded(
child: Padding(
child: TextField(
textAlign: TextAlign.center,
style: TextStyle(fontSize: 25),
keyboardType: TextInputType.number,
onChanged: (text) => mRiteText = text,
),
padding: EdgeInsets.all(10.0),
),
),
Expanded(
child: Container(
child: Padding(
// 연산 결과 표시. Text는 보여주기 전용 위젯
child: Text(mResult,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 25),
),
padding: EdgeInsets.all(10.0),
),
color: Colors.orange,
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
RaisedButton(
child: Text('덧셈'),
onPressed: () {
if(mLeftText != null && mRiteText != null) {
mKind = DataKind.ADD;
// postReply 함수는 비동기 함수여서 지연 처리
// then : 수신 결과를 멤버변수에 저장
// whenComplete : // 비동기 연산 완료되면 상태 변경
try {
postReply()
.then((recvd) => mResult = recvd)
.whenComplete(() {
if(mResult.isEmpty == false)
setState(() {});
});
} catch (e, s) {
print(s);
}
}
},
),
RaisedButton(
child: Text('곱셈'),
onPressed: () {
// 입력했다가 삭제하면 빈 문자열이 될 수 있다.
// 빈 문자열은 계산할 수 없으므로 예외 발생
// 숫자로 변환할 수 없는 문자열에 대해서도 예외가 발생하지만
// 숫자만 받을 수 있는 키보드를 사용함으로 해결했다.
if (mLeftText != null && mRiteText != null) {
mKind = DataKind.MULTIPLY;
postReply()
.then((recvd) => mResult = recvd)
.whenComplete(() {
if(mResult.isEmpty == false)
setState(() {});
});
}
}
),
],
),
],
);
}
}


29. 플러터 : 서버/클라이언트 연동 (4)

이전 글인 "28. 플러터 : 서버/클라이언트 연동 (3)"에서 언급한
사설 IP 주소로 파이썬 애니웨어를 대신하는 것에 대한 설명이다.
중요한 건 이전 글에 나왔으니까
여기서는 달라진 부분에 대해서만 언급하도록 한다.

이전 예제에서는 클라이언트에 해당하는 앱은 플러터로 구현하고
서버는 만들어져 있는 것을 가져다 사용했다.
이제 우리만의 서버를 직접 만들어서 연동하도록 하자.

윈도우에서는 cmd, 맥과 리눅스에서는 터미널을 실행하도록 한다.
cmd에서는 ipconfig, 터미널에서는 ifconfig 명령을 입력하면 IP 주소를 확인할 수 있다.
대부분 공유기에 연결된 네트웍을 사용하기 때문에 192로 시작하는 주소가 할당된다.
192로 시작하는 주소를 사설 IP 주소라고 부르고
공유기를 통해서만 외부로 나갈 수 있고, 외부에서 직접 연결할 수는 없는 주소를 말한다.

아래 화면은 맥북에서 캡쳐했고, "192.168.35.125"를 코드에서 사용한다.
이 부분은 반드시 자신의 IP 주소를 사용해야 한다.



이전 글과 달라진 부분은 마지막 줄의 run 함수 호출이다.
막혀 있던 주석을 풀었고, host 매개변수에 "192.168.35.125"를 전달했다.

from flask import Flask
import json

app = Flask(__name__)


# 루트 인터페이스. 서버 주소(127.0.0.1:5000)의 루트 폴더.
@app.route('/')
def index():
return 'home. nothing.'


# 루트 아래의 string 폴더에 접근할 때 보여줄 내용
@app.route('/string')
def send_string():
return '취미 : 서핑, 스노보드, 배드민턴'


# 사진은 앱에서 파일에 직접 접근하기 때문에 이름만 알려주면 된다.
@app.route('/image')
def send_image():
return 'book.jpg'


# 배열이나 사전 등의 프로그래밍 언어 객체는 문자열로 변환해서 전달해야 하고
# json 또는 xml 등을 사용하게 된다. 여기서는 json 사용.
@app.route('/array')
def send_array():
items = [
{'id': 12, 'content': '세상은 호락호락하지 않다. 괜찮다. 나도 호락호락하지 않다.'},
{'id': 45, 'content': '공부를 많이 하면 공부가 늘고 걱정을 많이 하면 걱정이 는다.'},
{'id': 78, 'content': '참아야 한다고 배워 힘든 걸 참고 행복도 참게 되었다.'},
]
return json.dumps(items)


if __name__ == '__main__':
app.run(host='192.168.35.125', debug=True)


결과 화면은 똑같다. 달라진 것이 없다.



파이썬 애니웨어의 주소만 "192.168.35.125"로 수정했다. 수정한 곳은 두 군데.


import 'dart:async';
import 'dart:convert'; // json 관련

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

// 수신 데이터의 종류를 상수로 설정
enum DataKind { NONE, STRING, IMAGE, ARRAY }

// 3가지 형태의 문자열 수신 : 단순 문자열, json 문자열, 이미지 주소 문자열.
Future<String> fetchString(DataKind kind) async {
if(kind == DataKind.NONE)
return '';

final details = ['', 'string', 'image', 'array'];
final urlPath = 'http://192.168.35.125/' + details[kind.index];

final response = await http.get(urlPath);

if (response.statusCode == 200)
return response.body;

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatefulWidget {
@override
State createState() {
return MyAppState();
}
}

class MyAppState extends State<MyApp> {
DataKind mKind = DataKind.NONE;

Widget makeChild(String str) {
switch (mKind) {
case DataKind.NONE:
return Placeholder(); // 아무 것도 수신하지 않았을 때 사용
case DataKind.STRING:
return Text(str, style: TextStyle(fontSize: 25),);
case DataKind.IMAGE:
return Image.network('http://192.168.35.125/static/' + str);
default: // str : 사전을 담고있는 배열 형태의 json 문자열.
final items = json.decode(str);
final textStyle = TextStyle(fontSize: 21, fontWeight: FontWeight.bold);

List<Container> widgets = [];
for(var item in items) {
widgets.add( // 여백을 통해 위젯을 보기좋게 배치하기 위해 Container 사용
Container(
child: Column(
children: <Widget>[
Text(item['id'].toString(), style: textStyle),
Text(item['content'], style: textStyle, softWrap: true),
],
),
padding: EdgeInsets.all(20.0),
),
);
}
return Column(children: widgets);
}
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
FutureBuilder(
future: fetchString(mKind),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Expanded(
child: Center(
child: makeChild(snapshot.data),
),
);
}
else if (snapshot.hasError) {
return Text('${snapshot.error}');
}

return Expanded(
child: Center(
child: CircularProgressIndicator(),
),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
RaisedButton(
child: Text('수신 안함'),
onPressed: () {
setState(() {
mKind = DataKind.NONE;
});
},
),
RaisedButton(
child: Text('문자열'), // 정직하게 함수를 두 번 호출하는 코드
onPressed: () {
setState(() {
mKind = DataKind.STRING;
});
},
),
RaisedButton(
child: Text('사진'), // 한 번은 정직하게, 한 번은 => 문법 사용
onPressed: () {
setState(() => mKind = DataKind.IMAGE);
},
),
RaisedButton(
child: Text('배열'), // 두 번 모두 => 문법 사용. 간단하지만 가독성에서는 어렵다.
onPressed: () => setState(() => mKind = DataKind.ARRAY),
),
],
),
],
);
}
}


9. 플러터 : 플랫폼 채널 기본 (2)

플러터는 안드로이드 스튜디오에서 코딩한다.
다른 IDE도 가능하겠지만
안드로이드 프로젝트를 주로 안드로이드 스튜디오에서 했기 때문에
그나마 이게 편하다.

특이하게도 플러터 프로젝트에 포함된 아이폰 프로젝트는 xcode에서 코딩할 수도 있게
안드로이드 프로젝트는 별도의 안드로이드 스튜디오에서 코딩할 수도 있게 만들었다.
자신 있는 코드라면 별도의 IDE 없이 코딩하면 되겠지만
자동완성을 비롯해 불편한 점이 많을 수 있다.
여기서는 별도의 IDE를 열고 설명하는 것이 불편하기 때문에
플러터 프로젝트 안에서 모두 처리하도록 한다.
말했다시피 에러가 발생하면 굉장히 곤란할 수 있음을 명심한다.

프로젝트를 완성한 다음에 성공적으로 구동하면
아래와 같은 화면을 볼 수 있다.
왼쪽은 처음 구동했을 때,
오른쪽은 아이폰/안드로이드에 데이터를 수신한 후의 모습이다.


아이폰 먼저 한다.
아이폰 소스코드는 ios 폴더의 Runner 폴더의 AppDelegate.swift에 있다.
플러터 프로젝트를 생성할 때
스위프트 옵션을 설정했기 때문에 확장자로 swift가 붙었다.

스위프트의 코드는 참 간결하다.
그런데 옵셔널 등의 다른 언어와는 다른, 반드시 이해해야 하는 문법들이 존재한다.
이것저것 참고해서 코드를 구성하기는 했는데
훨씬 효율적이고 정리가 잘 된 코드가 존재할 거라 확신한다.

xcode에서 코드를 입력하는 것이 자동완성으로 인해 너무 편했다.
xcode에서 수정하면 안드로이드 스튜디오에서 바로 반영되니까 xcode를 사용하지 않기는 어렵겠다.
윈도우 운영체제라면.. 글쎼.. 어떻게 해야 하나?
아이폰 시뮬레이터 실행은 안드로이드 상단에 위치한 도구막대에서 선택하면 된다.
자꾸 안 뜬다고 화내지 말기로 하자.

소스코드에 대한 설명은 아래 있는 안드로이드 코드를 보면 될 것 같다.
스위프트를 다시 보기 싫어서 그런다.
보고 나면 또 잊어먹을 것이고 다시 볼 일이 없을 거라고 믿는다.
이 부분은 다른 개발자에게 맡기는 걸로.
가령, 큰 아들 우재?

import UIKit

import Flutter


@UIApplicationMain

@objc class AppDelegate: FlutterAppDelegate {

    override func application(

        _ application: UIApplication,

        didFinishLaunchingWithOptions

        options: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

        let flutterView = window?.rootViewController as! FlutterViewController;

        let channel = FlutterMethodChannel(name: "mobile/parameters",

                                           binaryMessenger: flutterView)

        

        channel.setMethodCallHandler {

            (call: FlutterMethodCall, result: FlutterResult) in

            switch (call.method) {

            case "getMobileString":

                result("IOS")

            case "getMobileNumber":

                guard let args = call.arguments else {

                    return

                }

                let myArgs = args as! [String: Int32]

                let left = myArgs["left"]

                let rite = myArgs["rite"]

                result(left! * rite!)

            case "getMobileArray":

                let words = ["first", "second", "third"]

                result(words)

            default:

                result(FlutterMethodNotImplemented)

            }

        }

        

        // 빌트인 코드

        GeneratedPluginRegistrant.register(with: self)

        return super.application(application,

                                 didFinishLaunchingWithOptions: options)

    }

}


스위프트 버전을 만들 때 참고한 사이트를 소개한다.
특히 두 번째 사이트가 아이폰과 안드로이드 양쪽에 대해 체계적으로 설명하고 있다.
난 다 읽지는 않았고, 필요한 부분만 발췌해서 사용했다.

Flutter plugin: invoking iOS and Android method including parameters not working
Flutter Platform Channels


처음에 생각없이 오브젝티브-C로 만든 코딩을 했다.
어렵게 구한 코드가 스위프트가 아니었다는 이유로.
따로 저장하는 것도 이상하고 해서 오브젝티브-C 코드도 추가한다.
스위프트 옵션을 설정하지 않았을 때 사용한다.
AppDelegate.m 파일이다.

#include "AppDelegate.h"

#include "GeneratedPluginRegistrant.h"


@implementation AppDelegate


- (BOOL)application:(UIApplication *)application

        didFinishLaunchingWithOptions:(NSDictionary *)options {

    FlutterViewController* controller =

        (FlutterViewController*) self.window.rootViewController;


    // 채널 이름은 플러터와 정확히 일치해야 한다.

    FlutterMethodChannel* channel = [FlutterMethodChannel

        methodChannelWithName: @"mobile/parameters" binaryMessenger: controller];

    [channel setMethodCallHandler: ^(FlutterMethodCall* call, FlutterResult result) {

        if([@"getMobileString" isEqualToString: call.method]) {

            result(@"IOS");

        }

        else if([@"getMobileNumber" isEqualToString: call.method]) {

            // 전달 받은 key를 사용해서 데이터 추출

            NSNumber* left = call.arguments[@"left"];

            NSNumber* rite = call.arguments[@"rite"];


            // NSNumber 객체로부터 int 추출

            result(@(left.intValue * rite.intValue));

        }

        else if([@"getMobileArray" isEqualToString: call.method]) {

            NSArray* words = @[@"first", @"second", @"third"];

            result(words);

        }

        else {

            result(FlutterMethodNotImplemented);

        }

    }];


    // 빌트인 코드

    [GeneratedPluginRegistrant registerWithRegistry:self];

    return [super application:application didFinishLaunchingWithOptions:options];

}

@end


안드로이드는 아이폰과 비슷하다.
정확하게는 두 가지가 서로 비슷하다.
프로젝트를 구성하는 방법에 있어서 다르기 때문에 살짝 달라보이는 것일 뿐이다.

안드로이드 소스코드는
android, app, src, main, java 폴더를 따라 들어가면 마지막에 있는 MainActivity.java 파일에 있다.
플러터 프로젝트를 생성할 때
코틀린 옵션을 설정하지 않았기 때문에 확장자로 java가 붙었다.

플러터와 통신하기 위해서는
메소드 채널(MethodChannel) 객체를 만들고
메소드 채널 객체의 핸들러(setMethodCallHandler)를 등록하고
핸들러 안에서 처리할 이벤트를 구분하는 onMethodCall 함수를 구현하면 된다.

아래 코드에서 주의깊게 볼 부분은
플러터쪽으로 데이터를 전달하는 Result 객체에 반환값을 넣는 부분이다.
모든 데이터를 반환할 수 있어야 하기 때문에
Result 객체에는 무엇이건 넣을 수 있지만 1개만 넣을 수 있고, 플러터쪽에서 정확하게 자료형을 일치시켜야 한다.

package tf.com.gluesoft.tflite_flutter_1;

import android.os.Bundle;
import java.util.ArrayList; // 배열 사용 필수

// 기본적으로 포함되어 있는 모듈
import io.flutter.app.FlutterActivity;
import io.flutter.plugins.GeneratedPluginRegistrant;

// MethodChannel 관련 모듈 (추가해야 함)
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
import io.flutter.plugin.common.MethodChannel.Result;
import io.flutter.plugin.common.MethodCall;

public class MainActivity extends FlutterActivity {
// 채널 문자열. 플러터에 정의한 것과 동일해야 한다.
private static final String mChannel = "mobile/parameters";

@Override
protected void onCreate(Bundle savedInstanceState) {
// 빌트인 코드
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);

// 추가한 함수.
new MethodChannel(getFlutterView(), mChannel).setMethodCallHandler(
new MethodCallHandler() {
@Override
public void onMethodCall(MethodCall call, Result result) {
// 각각의 메소드를 switch로 분류. 아이폰에서는 else if 사용.
switch (call.method) {
case "getMobileString":
result.success("ANDROID");
break;
case "getMobileNumber":
// 매개 변수 추출
final int left = call.argument("left");
final int rite = call.argument("rite");
result.success(left * rite);
break;
case "getMobileArray":
// 배열 생성 후 전달. success 함수에 넣으면 json으로 인코딩된다. 플러터에서는 json 문자열 디코딩.
ArrayList<String> words = new ArrayList<>();
words.add("one");
words.add("two");
words.add("three");
result.success(words);
break;
default:
result.notImplemented();
}
}
}
);
}
}


안드로이드 코드 구성 중에 문제가 있는 것처럼 보일 수도 있다.
가령, "Cannot resolve symbol android" 에러가 뜨지만 잘못된 것은 아니다.
플러터 프로젝트에서 안드로이드 프로젝트 인식을 말끔하게 하지 못하는 것처럼 보인다.

이 부분은 안드로이드 프로젝트만 떼어서
다른 안드로이드 스튜디오로 열어서 gradle sync를 해도 달라지지 않는다.

오른쪽 상단에 Setup SDK 명령이 뜨면,
gradle 파일에 있는 버전 선택하면 해결된다. 꼭 해당 버전을 선택해야 한다.

여러 가지가 섞이니까
뭘 만들던지 쉽게 되지 않는다.
잘 따라왔다.

8. 플러터 : 플랫폼 채널 기본 (1)

일단 처음 생각했던 부분까지 왔다.
지금 작성하는 시리즈까지 정리하고 나면
본업으로 삼아야만 하는 딥러닝에 집중할 수 있을 것이다.

플러터만으로 스마트폰 앱을 완벽하게 구성할 수는 없다.
모바일이라는 공통 분모가 상당 부분 존재하지만
아이폰과 안드로이드라는 이질적인 부분도 존재하기 때문이다.
이와 같은 이질적인 부분에 대해
플러터에서는 아이폰이나 안드로이드에서 코딩하는 것처럼 코딩할 수 있도록 해준다.

아이폰이나 안드로이드와 연동하기 위해서는 플랫폼 채널이 필요한데
이번 글에서는 기본이 되는 이 부분에 대해 집중하도록 하겠다.

플러터 프로젝트에서 생성한 버튼을 누르면
아이폰 시뮬레이터 또는 안드로이드 에뮬레이터로부터 해당 버튼에 맞는 데이터를 수신한 다음
플로터 프로젝트에서 만든 텍스트뷰에 결과를 표시한다.
통신 데이터의 종류로는 문자열 1개, 정수 1개, 문자열 배열 1개를 사용한다.

먼저 플러터 프로젝트를 생성한다.
프로젝트 이름은 tflite_flutter_1로 한다.

이후 글에서도 똑같이 설정해야 하는 부분이 있는데
아래 그림에 있는 것처럼 안드로이드 코딩에서 코틀린을 사용하지 않을 것이다.
한창 앱을 개발할 때는 코틀린이 없었다.
덕분에 배우지 않아도 됐고, 기본은 알고 있는 자바로 코딩을 진행할 것이다.
그러고 보니
이전 글에서도 안드로이드 프로젝트는 모두 자바로 코딩을 했다.

플러터 프로젝트를 만드는 것이 생소하면
플러터 카테고리에 있는 글을 몇 개 보고 오도록 한다.
따로 설명하지 않는다.

안드로이드의 코틀린(kotlin)은 선택하지 않았고
아이폰의 스위프트(swift)는 선택했다.
앱 개발 주력 언어가 스위프트였는데.. 그마저 가물가물 하는 중이다.


플러터 프로젝트에서 라이브러리와 같은 환경 설정은 pubspec.yaml 파일에서 처리한다.
이번엔 플랫폼 채널외에는 별게 없어 pubspec.yaml 파일을 수정하지 않는다.

lib 폴더 아래의 main.dart 파일을 연다.
스마트폰에 표시할 화면을 비롯해서 아이폰/안드로이드와 연동할 코드를 추가한다.
플러터를 비롯해서 다트까지, 코드에 대한 설명은 생략한다.
꼭 플러터 카테고리의 내용을 읽고 오도록 한다.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class PlatformChannel extends StatefulWidget {
@override
PlatformChannelState createState() => PlatformChannelState();
}

// State를 상속 받아야 setState 함수를 사용해서 화면 구성을 쉽게 변경할 수 있다.
class PlatformChannelState extends State<PlatformChannel> {
// 채널 생성. 매개변수에 '/'는 중요하지 않다. 'mobile' 영역의 'parameters'라는 항목으로 분류하기 위해 사용.
// 채널을 여러 개 만들어도 되고, 채널 하나에 메소드를 여러 개 얹어도 된다.
// 여기서는 채널 1개에 매개변수와 반환값을 다르게 해서 메소드를 3개 사용한다.
static const MethodChannel mMethodChannel = MethodChannel('mobile/parameters');

// 아이폰/안드로이드에서 수신한 데이터 : 문자열, 정수, 문자열 배열
String mReceivedString = 'Not clicked.';
int mReceivedNumber = -1;
String mReceivedArray = 'Not received.';

// 아이폰/안드로이드로부터 문자열 수신 (비동기)
Future<void> getMobileString() async {
try {
// getMobileText 문자열은 안드로이드와 아이폰에 전달할 식별자.
// if문으로 문자열을 비교해서 코드를 호출하기 때문에 함수 이름과 똑같을 필요는 없다.
// 반환값은 정해진 것이 없고 모든 자료형 사용 가능
final String received = await mMethodChannel.invokeMethod('getMobileString');

// 상태를 변경하면 build 메소드를 자동으로 호출하게 된다.
// 상태를 변경한다는 뜻은 setState 함수를 호출하는 것을 뜻한다. 변수의 값을 바꾸는 것은 없어도 된다.
setState(() {
mReceivedString = received;
});
}
on PlatformException {
mReceivedString = 'Exception. Not implemented.';
}
}

// 아이폰/안드로이드로부터 정수 수신 (비동기)
Future<void> getMobileNumber() async {
try {
// 여러 개의 매개 변수는 맵을 통해 전달
const values = <String, dynamic>{'left': 3, 'rite': 9};
final int received = await mMethodChannel.invokeMethod('getMobileNumber', values);

setState(() {
mReceivedNumber = received;
});
}
on PlatformException {
mReceivedNumber = -999;
}
}

// 아이폰/안드로이드로부터 문자열 배열 수신 (비동기)
Future<void> getMobileArray() async {
try {
// 문자열 배열 수신
final List<dynamic> received = await mMethodChannel.invokeMethod('getMobileArray');

setState(() {
mReceivedArray = '${received[0]}, ${received[1]}, ${received[2]}';
});
}
on PlatformException {
mReceivedArray = 'Exception. Not implemented.';
}
}

// 화면 구성. 유저 인터페이스를 구축한다고 보면 된다.
// 안드로이드에서 사용하는 별도의 xml 파일 없이 직접 코딩으로 화면을 설계한다.
// 아이폰/안드로이드로부터 넘겨받은 데이터를 사용해서 위젯의 내용을 덮어쓴다.
@override
Widget build(BuildContext context) {
return Material(
child: Column(
children: <Widget>[
Column(
children: <Widget>[
Text(mReceivedString, style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get text!', style: TextStyle(fontSize: 23)),
onPressed: getMobileString,
),
],
),
Column(
children: <Widget>[
Text(mReceivedNumber.toString(), style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get number!', style: TextStyle(fontSize: 23)),
onPressed: getMobileNumber,
),
],
),
Column(
children: <Widget>[
Text(mReceivedArray, style: TextStyle(fontSize: 23)),
RaisedButton(
child: Text('Get array!', style: TextStyle(fontSize: 23)),
onPressed: getMobileArray,
),
],
),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
);
}
}

// 플러터 프로젝트의 진입점(entry point)
void main() {
runApp(MaterialApp(home: PlatformChannel()));
}


많이 했다.
다음 글에서 아이폰과 안드로이드 코딩을 해보자.

7. 텐서플로 라이트 : 안드로이드 + mnist 모델(케라스) (2)

안드로이드 프로젝트를 만들자.
프로젝트 이름은 tflite_by_keras로 줬다.

프로젝트를 만들고 나서 실행하면 아래와 같은 결과를 볼 수 있다.
첫 번째는 숫자 이미지 3장을 업로드한 에뮬레이터의 Downloads 폴더를 보여주고
두 번째와 세 번째는 선택한 숫자의 이미지에 대해 예측한 결과를 보여준다.

먼저 app 폴더에 있는 build.gradle 파일이다.
이전 글에서 설명했듯이 압축하면 안되고 텐서플로 라이트 모듈을 사용하자.

apply plugin: 'com.android.application'

android {
// 생략..

aaptOptions {
noCompress "tflite"
}
}

dependencies {
// 생략..

implementation 'org.tensorflow:tensorflow-lite:+'
}


앞의 그림과 똑같이 만들어 보자.
상단에 이미지뷰, 중간에 텍스트뷰 10개, 하단에 버튼 1개.
간격을 균등하게 분배하기 위해 layout_weight 속성을 사용했다.
activity_main.xml 파일을 아래 코드로 바꾸자.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<FrameLayout
android:layout_weight="1"
android:layout_width="match_parent"
android:layout_height="0dp">
<ImageView
android:id="@+id/photo"
android:layout_gravity="center"
android:layout_width="300dp"
android:layout_height="300dp" />
</FrameLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal">
<TextView
android:id="@+id/result_0"
android:text="result 0"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_1"
android:text="result 1"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_2"
android:text="result 2"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_3"
android:text="result 3"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_4"
android:text="result 4"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:orientation="horizontal">
<TextView
android:id="@+id/result_5"
android:text="result 5"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_6"
android:text="result 6"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_7"
android:text="result 7"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_8"
android:text="result 8"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
<TextView
android:id="@+id/result_9"
android:text="result 9"
android:textSize="19sp"
android:gravity="center"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent" />
</LinearLayout>

<Button
android:id="@+id/button"
android:text="숫자 선택"
android:textSize="19sp"
android:layout_width="match_parent"
android:layout_height="50dp" />
</LinearLayout>


마지막으로 안드로이드 코드가 포함된 MainActivity.java 파일이다.
이미지를 앨범에서 선택하는 코드와
선택한 이미지를 비트맵으로 변환하는 코드와
비트맵을 바이트 배열로 변환하는 코드와
텐서플로 라이트 모델을 적용하는 코드와
예측 결과를 텍스트뷰에 출력하는 코드로 이루어져 있다.

맨 아래 있는 함수 2개는 공통 함수라서 안 봐도 되고
onCreate와 onActivityResult 함수만 보도록 한다.
앞에 설명했던 것처럼 각각의 코드가 어떤 역할을 하는지 정리할 수 있어야 한다.

package com.example.tflite_by_keras;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.util.Arrays;


public class MainActivity extends AppCompatActivity {
private static final int FROM_ALBUM = 1; // onActivityResult 식별자
private static final int FROM_CAMERA = 2; // 카메라는 사용 안함

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// 인텐트의 결과는 onActivityResult 함수에서 수신.
// 여러 개의 인텐트를 동시에 사용하기 때문에 숫자를 통해 결과 식별(FROM_ALBUM 등등)
findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent();
intent.setType("image/*"); // 이미지만
intent.setAction(Intent.ACTION_GET_CONTENT); // 카메라(ACTION_IMAGE_CAPTURE)
startActivityForResult(intent, FROM_ALBUM);
}
});
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
// 카메라를 다루지 않기 때문에 앨범 상수에 대해서 성공한 경우에 대해서만 처리
if (requestCode != FROM_ALBUM || resultCode != RESULT_OK)
return;

try {
// 선택한 이미지에서 비트맵 생성
InputStream stream = getContentResolver().openInputStream(data.getData());
Bitmap bmp = BitmapFactory.decodeStream(stream);
stream.close();

ImageView iv = findViewById(R.id.photo);
iv.setScaleType(ImageView.ScaleType.FIT_XY); // [300, 300]에 꽉 차게 표시
iv.setImageBitmap(bmp);

// ---------------------------------------- //
// 검증 코드. 여러 차례 변환하기 때문에 PC 버전과 같은지 확인하기 위해 사용.

// mnist 원본은 0~1 사이의 실수를 사용해 픽셀을 표현한다. 픽셀 1개에 1바이트가 아니라 4바이트 사용.
// 메모리 용량은 3136(28 * 28 * 4). 입력 이미지를 똑같이 만들어서 전달해야 한다.

// mnist에서 생성한 숫자 이미지 파일이 흑백이긴 하지만 ARGB를 사용해서 색상을 표시하기 때문에
// 가운데 픽셀의 경우 fffcfcfc와 같은 형태로 나온다.
// ff는 alpha를 가리키고 동일한 값인 fc가 RGB에 공통으로 나온다.

// getPixel 함수는 int를 반환하기 때문에 부호 없는 16진수로 확인해야 한다.
// int pixel = bmp.getPixel(14, 14);
// Log.d("getPixel", Integer.toUnsignedString(pixel, 16));

// 원본 mnist 이미지는 (28, 28, 1)로 되어 있다.
// getByteCount 함수로 확인해 보면 3136으로 나오는데
// 각각의 픽셀이 4바이트로 구성되어 있기 때문에 그렇다. 784 * 4 = 3136
// int bytes = bmp.getByteCount();
// Log.d("getByteCount", Integer.toString(bytes));

// mnist 원본 이미지와 비교하기 위해 줄 단위로 변환 결과 출력
// 파이썬에서 똑같은 파일을 읽어들여서 에뮬레이터 출력과 비교. 똑같이 나온다. 성공.
// 2차원 배열을 한 번에 깔끔하게 출력할 수 없기 때문에 아래 코드가 필요하다.
// float[] row = new float[28];
// for(int y = 0; y < 28; y++) {
// for(int x = 0; x < 28; x++) {
// int pixel = bmp.getPixel(x, y); // x가 앞쪽, y가 뒤쪽.
// row[x] = (pixel & 0xff) / (float) 255; // 실수 변환하지 않으면 0과 1로만 나온다.
// }
// // 줄 단위 출력. 그래도 자릿수가 맞지 않아 numpy처럼 나오진 않는다.
// Log.d(String.format("%02d", y), Arrays.toString(row));
// }

// ---------------------------------------- //

// 비트맵 이미지로부터 RGB에 해당하는 값을 1개만 가져와서
// mnist 원본과 동일하게 0~1 사이의 실수로 변환하고, 1차원 784로 만들어야 한다.
// 그러나, 실제로 예측할 때는 여러 장을 한 번에 전달할 수 있어야 하기 때문에
// 아래와 같이 2차원 배열로 만드는 것이 맞다.
// 만약 1장에 대해서만 예측을 하고 싶다면 1차원 배열로 만들어도 동작한다.
float[][] bytes_img = new float[1][784];

for(int y = 0; y < 28; y++) {
for(int x = 0; x < 28; x++) {
int pixel = bmp.getPixel(x, y);
bytes_img[0][y*28+x] = (pixel & 0xff) / (float) 255;
}
}

// 파이썬에서 만든 모델 파일 로딩
Interpreter tf_lite = getTfliteInterpreter("mnist.tflite");

// 케라스로부터 변환할 때는 입력이 명시되지 않기 때문에 입력을 명확하게 정의할 필요가 있다.
// 이때 getInputTensor 함수를 사용한다. getOutputTensor 함수도 있다.
// 입력은 1개밖에 제공하지 않았고, 784의 크기를 갖는 1차원 이미지.
// 입력이 1개라는 뜻은 getInputTensor 함수에 전달할 인덱스가 0밖에 없다는 뜻이다.
// 여러 장의 이미지를 사용하면 shape에 표시된 1 대신 이미지 개수가 들어간다.
// input : [1, 784]
// Tensor input = tf_lite.getInputTensor(0);
// Log.d("input", Arrays.toString(input.shape()));

// 출력 배열 생성. 1개만 예측하기 때문에 [1] 사용
// bytes_img에서처럼 1차원으로 해도 될 것 같은데, 여기서는 에러.
float[][] output = new float[1][10];
tf_lite.run(bytes_img, output);

Log.d("predict", Arrays.toString(output[0]));

// 텍스트뷰 10개. 0~9 사이의 숫자 예측
int[] id_array = {R.id.result_0, R.id.result_1, R.id.result_2, R.id.result_3, R.id.result_4,
R.id.result_5, R.id.result_6, R.id.result_7, R.id.result_8, R.id.result_9};

for(int i = 0; i < 10; i++) {
TextView tv = findViewById(id_array[i]);
tv.setText(String.format("%.5f", output[0][i])); // [0] : 2차원 배열의 첫 번째
}

} catch (Exception e) {
e.printStackTrace();
}
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


벌써 일요일이 다가고 있다.

내일부터는 수업 때문에 바쁠 것이고
나는 또 지금 작성했던 내용을 기억으로부터 밀어낼 것이고.

어찌 됐든 지금 코드를 활용하면 플러터 버전으로 변환할 수 있다.
플러터에서는 이미지 선택과 결과 표시를 담당하고
안드로이드에서는 입력으로 들어온 이미지를 모델에 전달해서 결과를 받는다.
아직 플러터에서 텐서플로 라이트에 대해 공식적으로 지원하지 않고 있어서
안드로이드 혹은 아이폰 코드와 연동하지 않을 수 없다.

하고 나니 복잡할 게 없는데
왜 1주일씩이나 걸린 것일까..?
욕하고 싶다.

6. 텐서플로 라이트 : 안드로이드 + mnist 모델(케라스) (1)

딥러닝 모델을 스마트폰과 연동하고 있는데
왜 이리 할게 많은 것일까?
기본 단계에서는 사진까지만 처리할 수 있으면 되는데..

케라스 모델을 텐서플로 라이트 버전으로 수정하는 것은 또 달랐다.
입력과 출력을 지정하지 않는 것은 좋은데
스마트폰에서는 어떻게 해야 하는 것일까?

사진을 다루기 때문에 코드가 꽤 길다.
파이썬으로 모델을 만들고
해당 모델을 안드로이드에서 가져다 사용하는 방식으로 진행한다.

ㅎㅎ
이전 글에서 새로 바뀐 편집기가 코드 색상을 지원하지 않아서 엄청 욕했다.
확인해 보니 예전 편집기를 사용할 수 있는 방법이 있었다.
불편하긴 하지만, 코드를 여러 색상으로 보여주는 건 타협할 수 없다.

먼저 mnist 데이터셋을 소프트맥스 알고리즘으로 분류하는 모델을 저장한다.
케라스를 사용하기 때문에 코드가 참 짧고 보기 좋다.
에포크는 15번 반복한다. 정확도는 92%를 조금 넘고, 파일 이미지에 대해서는 30장 기준으로 98%를 넘는다.

import tensorflow as tf
from tensorflow.examples.tutorials.mnist import input_data
import os
import numpy as np
import PIL

np.set_printoptions(linewidth=1000)


def save_model(h5_path, model_path):
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(10, activation='softmax', input_shape=[784]))

model.compile(optimizer=tf.keras.optimizers.Adam(lr=0.001),
loss=tf.keras.losses.sparse_categorical_crossentropy,
metrics=['acc'])

mnist = input_data.read_data_sets('mnist')
model.fit(mnist.train.images, mnist.train.labels,
validation_data=[mnist.validation.images, mnist.validation.labels],
epochs=15, batch_size=128, verbose=0)

# 케라스 모델과 변수 모두 저장
model.save(h5_path)

# -------------------------------------- #

# 저장한 파일로부터 모델 변환 후 다시 저장
converter = tf.lite.TFLiteConverter.from_keras_model_file(h5_path)
flat_data = converter.convert()

with open(model_path, 'wb') as f:
f.write(flat_data)


save_model('./model/mnist.h5', './model/mnist.tflite')


이전에 만든 글에서 mnist 데이터셋을 파일로 저장하는 내용을 다뤘다.

mnist 숫자를 파일로 저장

왜 직접 손으로 써서 카메라로 촬영하면 안 되는 것인지에 대해 설명했다.
그렇다면 저장한 파일을 읽어와야 해당 숫자에 대해 예측할 수 있다는 뜻이다.
특정 폴더에 포함된 모든 파일을 읽어오는 함수를 만들었다.
주의할 점은 이 폴더의 내용은 반드시 mnist 숫자 이미지를 변환한 파일이어야 한다.
나는 이전 글의 코드를 사용해서 숫자별로 5장씩 50장을 저장했다.
그러나, 안드로이드 에뮬레이터에는 3장만 저장해서 사용했다.

다시 얘기하겠지만 중요한 점이 있다.
반드시 PC와 에뮬레이터의 파일은 같은 내용이어야 한다.
물론 여기서 만든 예제는 잘 동작하기 때문에 비교하지 않아도 되지만
이 부분 때문에 1주일이나 걸렸다.
잘 안되니까 하기 싫고.. 중간에 서핑도 조금 하고.

def read_testset(dir_path):
filenames = os.listdir(dir_path)
# filenames = ['0_003.png', '1_005.png', '2_013.png']

# images를 배열이 들어있는 리스트로 생성하면 에러
images = np.zeros([len(filenames), 784], dtype=np.float32)
labels = np.zeros([len(filenames)], dtype=np.int32)

for i, filename in enumerate(filenames):
img = PIL.Image.open(os.path.join(dir_path, filename))
# gray 스케일 변환
img = img.convert("L")
# 원본이 mnist와 맞지 않는다면 필요
img = img.resize([28, 28])
# PIL로 읽어오면 0~255까지의 정수. 0~1 사이의 실수로 변환.
# 생략하면 소프트맥스 결과가 하나가 1이고 나머지는 모두 0. 확률 개념이 사라짐.
# 255로 나누면 원본과 비교해서 오차가 있긴 하지만, 정말 의미 없는 수준이라서 무시해도 된다.
img = np.uint8(img) / 255
# 2차원을 mnist와 동일한 1차원으로 변환. np로 변환 후에 reshape 호출
images[i] = img.reshape(-1)

# 레이블. 이름의 맨 앞에 정답 있다.
finds = filename.split('_') # 0_003.png
labels[i] = int(finds[0]) # 0

return images, labels


read_testset 함수를 사용해서 특정 폴더의 내용을 모두 가져와서 예측해 보자.
나는 new_data라는 이름의 폴더를 사용했다.

def load_model(h5_path, dir_path):
model = tf.keras.models.load_model(h5_path)

mnist = input_data.read_data_sets('mnist')
print('mnist :', model.evaluate(mnist.test.images, mnist.test.labels))

# 파일로 변환한 mnist 숫자 이미지 파일 읽
images, labels = read_testset(dir_path)
print('files :', model.evaluate(images, labels))

# 에뮬레이터 결과와 비교 목적
preds = model.predict(images)
print(preds)


load_model('./model/mnist.h5', './mnist/new_data')

# [출력 결과]
# 10000/10000 [==============================] - 0s 21us/sample - loss: 0.2636 - acc: 0.9262
# mnist : [0.26363739772737027, 0.9262]
# 50/50 [==============================] - 0s 64us/sample - loss: 0.1373 - acc: 0.9800
# files : [0.13729526340961457, 0.98]
# [[9.81748290e-03 9.09259825e-07 2.68990714e-02 1.55930538e-02 2.32001161e-03 3.85744683e-02 8.65096133e-03 1.09144221e-05 8.93410563e-01 4.72251419e-03]
# [9.44603598e-05 1.64911069e-06 2.58899899e-03 4.38177539e-03 4.03641279e-05 6.74966127e-02 2.20050606e-06 3.07728811e-08 9.24911320e-01 4.82621283e-04]
# [5.07208024e-05 1.35197956e-06 3.72361648e-03 3.30108742e-04 9.75759685e-01 8.81002983e-04 8.18982720e-04 5.65604598e-04 9.81913181e-04 1.68870017e-02]
# 이하 생략


추가로 모델과 상관없지만
결과가 너무 이상하게 나왔었기 때문에
에뮬레이터와 계속해서 비교하지 않을 수 없었다.
가장 중요했던 것은 원본 이미지와 에뮬레이터에서 사용하는 이미지의 픽셀 값이 같은 것인지,였다.
그래서 PC에서는 아래 함수를 만들어서 사용했다.

# 파일 이미지 출력
def show_image_values(file_path):
img = PIL.Image.open(file_path)

img = img.convert("L")
img = img.resize([28, 28])
img = np.uint8(img) / 255

print(img)


# pc와 에뮬레이터에 같은 파일을 넣고 실제 값 출력해서 비교.
# 똑같이 나왔다. 변환이 잘 되었다는 뜻.
show_image_values('./mnist/new_data/2_013.png')

# [출력 결과] 너무 길어서 앞뒤의 0에 대해 공백을 제거했다.
# 생략..
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.09411765 0.9254902 0.44313725 0. 0. 0. 0. 0.5254902 0.99215686 0.99215686 0.25490196 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.00392157 0.03529412 0. 0. 0. 0. 0.28627451 0.97647059 0.99215686 0.52156863 0.01176471 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.08627451 0.97647059 0.99215686 0.84705882 0.12156863 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.23529412 0.96078431 0.99215686 0.92156863 0.1254902 0. 0. 0. 0. 0. 0. 0. 0.]
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.21568627 0.80784314 0.99215686 0.98431373 0.45882353 0. 0. 0. 0. 0. 0. 0. 0. 0.]
# 생략..


이번 글에서 필요한 것은
텐서플로 라이트 모델로 변환한 "mnist.tflite" 모델 파일이다.
나머지는 중간 과정.

mnist 숫자를 파일로 저장

손으로 쓴 숫자를 딥러닝으로 분류하고 싶다.
손으로 쓴 다음 카메라로 찍거나
그림판에서 동일한 크기로 마우스로 숫자를 그리거나 해보면
결과가 잘 나오지 않는다.
그렇다고 검색한 결과를 캡쳐하는 것은 바보 같다는 생각이 들고.

결과가 잘 나오지 않는 것은
이미지가 너무 작아서 축소하는 과정에서
mnist 원본과 같은 분포의 픽셀이 잘 만들어지지 않기 때문이다.
잘 할 수 있는 사람도 있겠지만
나는 이런저런 실패만 맛보았다.

이걸 하고 싶었던 이유는
스마트폰에 딥러닝 모델을 얹고 이미지로 결과를 보고 싶은데
mnist 모델이 가장 쉽고 단순하기 때문이다.
사진을 촬영하는 것 대신
다운로드한 파일을 읽어서 처리하면 같은 방식이기 때문에
숫자 이미지 파일이 필요했다.

구현하다 보니까
점점 정교한 이미지가 필요해서 결국 3가지 형태로 발전했다.
완전 정교하진 않지만 마지막 방법을 가장 선호하지 않을까 싶다, 나처럼!

먼저 공통 함수 몇 개를 정의했다.
티스토리 에디터가 업그레이드되고 나서 코드에 색상이 들어가지 않는다.
코드 블록을 넣어서 처리하도록 되었는데
편집할 때는 존재하던 색상이 외부에서 접근하게 되면 사라진다.
티스토리, 대단하다.
이런 코드를 보게 해줘서 고맙고 카카오의 미래는 참 밝아 보인다.

from tensorflow.examples.tutorials.mnist import input_data
import matplotlib.pyplot as plt
import numpy as np
import os


# mnist 데이터셋 중에서 test 데이터셋 사용
# 'mnist' 폴더에 파일 다운로드
def get_dataset():
    mnist = input_data.read_data_sets('mnist')
    return mnist.test.images, mnist.test.labels


# 파일을 저장할 폴더 확인 후 생성
def make_directory(dir_path):
    if not os.path.exists(dir_path):
        os.mkdir(dir_path)


# 파일 이름은 한 자리 숫자가 앞에 오고, 세 자리 일련번호가 뒤에 온다.
# 일련번호 최대 숫자는 999이기 때문에 1천장을 넘어가면 형식이 어긋난다.
def get_file_path(digit, order, dir_path):
    filename = '{}_{:03d}.png'.format(digit, order)
    return os.path.join(dir_path, filename)

 

첫 번째 저장 코드는 가장 단순한 형태.
test 데이터셋의 처음부터 지정한 개수만큼 저장한다.
이때 파일 이름은 앞에 정답에 해당하는 label이 오고 뒤에 몇 번째 파일인지를 가리키는 일련번호가 온다.

# mnist의 test 데이터셋으로부터 지정한 개수만큼 파일로 변환해서 저장
def make_digit_images_from_mnist(count, dir_path):
    images, labels = get_dataset()

    make_directory(dir_path)

    if count > len(images):
        count = len(images)

    for i in range(count):
        file_path = get_file_path(labels[i], i, dir_path)   # i는 일련번호 + 배열 인덱스
        plt.imsave(file_path, images[i].reshape(28, 28), cmap='gray')


make_digit_images_from_mnist(30, './mnist/new_data')

 

두 번째는 난수 개념을 추가했다.
매번 앞에서만 가져오면 항상 똑같은 순서와 똑같은 숫자만 가져오니까.
사람이라면 이런 생각이 나지 않을 수 없다.

# 난수 샘플링 적용
def make_random_images_from_mnist(count, dir_path):
    images, labels = get_dataset()

    make_directory(dir_path)

    if count > len(images):
        count = len(images)

    # 중복되지 않는 인덱스 추출
    series = np.arange(len(images))
    indices = np.random.choice(series, count, replace=False)

    for i, idx in enumerate(indices):
        # i는 일련번호, idx는 배열의 인덱스
        file_path = get_file_path(labels[idx], i, dir_path)
        plt.imsave(file_path, images[idx].reshape(28, 28), cmap='gray')


make_random_images_from_mnist(30, './mnist/new_data')

 

세 번째는 생성되는 숫자 개수를 똑같이 맞췄다.
앞의 방법들로 가져와서 보면 샘플 숫자의 개수가 일정하지 않아서
예측에 사용하기에는 많이 불편했다.
코드는 길어졌지만, 복사해서 사용할거니까 이걸 써야겠다.

# 숫자별로 지정한 개수만큼 동일하게 추출. 추출 개수는 digit_count * 10.
def make_random_images_from_mnist_by_digit(digit_count, dir_path):
    images, labels = get_dataset()

    make_directory(dir_path)

    if digit_count > len(images) // 10:
        digit_count = len(images) // 10

    # 동일 개수를 보장해야 하므로 전체 인덱스 필요
    indices = np.arange(len(images))
    np.random.shuffle(indices)

    # 0~9까지 10개 key를 사용하고, value는 0에서 시작
    extracted_digit = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0}
    extracted_total = 0

    for idx in indices:
        digit = labels[idx]

        # 숫자별 최대 개수를 채웠다면, 처음으로.
        if extracted_digit[digit] >= digit_count:
            continue

        # 현재 숫자 증가
        extracted_digit[digit] += 1

        file_path = get_file_path(digit, extracted_total, dir_path)
        plt.imsave(file_path, images[idx].reshape(28, 28), cmap='gray')

        # 추출 숫자 전체와 추출해야 할 개수 비교
        extracted_total += 1
        if extracted_total >= digit_count * 10:
            break


make_random_images_from_mnist_by_digit(digit_count=2, dir_path='./mnist/new_data')

 

생성된 파일을 안드로이드 에뮬레이터에 넣는 방법은 아래 사이트를 참고하자.
아이폰 시뮬레이터는 직접 찾아보자.
요즘엔 대부분의 테스트를 안드로이드에서 하고 있다. ^^

[Cordova] android studio emulater에 이미지 파일 넣는 방법

윈도우10 + 텐서플로 GPU 버전 설치

얼마만인지 모르겠다.
블로그에 올린 많은 글들 중에
나름 봐줬으면 하는 글들이 꽤 있는데
통계를 보면 우분투에 텐서플로 GPU 버전을 설치하는 글이 가장 인기가 많다.
왜 그런지는 모르겠다.
아직 그 부분이 해결되지 않았나?

엔비디아 1060에 GPU 버전을 설치해서 쓰고 있었는데
애들 핑계로 1070을 구매했고 지나가는 길에 설치를 시도했는데.. 잘 되지 않았다.
큰 아들 우재한테 시켰더니
며칠째 끙끙대기만 하고 결과가 나오지 않는다.

첫 번째 설치는 당연히 실패하고
여기저기 찾아다닌 끝에 3시간만에 성공했다.
그 후에 이 방식이 맞는지 검증하기 위해 다시 3시간을 보냈다.

설치 환경을 요약하면 아래와 같다.

- 운영체제 : 윈도우 10
- 그래픽카드 : 엔비디아 1070 TI
- 드라이버 : 419.67
- CUDA : 10.0
- cudnn : 10.0
- 파이썬 : 3.6.8
- 텐서플로 GPU : 1.13.1

 

2019년 3월 31일 현재 최신 버전은 CUDA 10.1 버전이다.
그런데.. 설치가 되질 않는다.
10.0을 설치하는 방법과 똑같이 진행을 했는데.. 실패다.
대여섯번 하다가 안 되면 그만할 줄도 알아야 한다.
컴퓨터 2대에서 시도했고, 아쉽지만 이번에는 10.0에서 만족하기로 했다.

파이썬은 미리 설치되어 있다고 가정한다.
엔비디아 그래픽 카드 드라이버는 
기본 설치 버전이 있는 상태에서 최신 버전인 419.67 버전을 설치했더니
자연스럽게 기본 버전은 제거가 됐다.

 

윈도우10에 설치된 엔비디아 그래픽 카드 프로그램 목록

 

얼마나 실패를 할지 몰라
이번에도 단단히 준비를 하고 시작했다.
GPU 버전 설치는 어찌 보면 다양한 버전들로부터 궁합이 잘 맞는 버전을 찾는 문제일 수 있다.

다운로드 받아 놓은 설치 파일들

 

설치 파일을 다운로드할 수 있는 사이트를 정리했다.
맨 앞의 텐서플로 공식 사이트에서 말하는 내용 정도는 보는 것이 좋겠다.
최신 버전을 설치하려면
시도한 사람이 많지 않은 상태에서는 부족하긴 하지만 공식 문서가 최신일 수도 있다.

 

 

설치 파일을 다운로드하는 화면은 넣지 않았다.
운영체제 버전 선택하는 그런 것들이 필요해 보이지는 않는데..
다만 설치 환경이 모두 다를 수 있기 때문에
자신의 환경에 맞는 버전을 정확하게 선택해야 할 것이다.

 

그래픽 카드 드라이버 다운로드

 

GPU 버전 설치는 CUDA와 cuDNN 설치가 전부일 수 있다.
가장 최신의 버전인 CUDA 10.1은 설치할 수 없어서 10.0 버전을 설치했다고 앞에서 얘기했다.

 

1. CUDA 10.0 설치
그림 상단에 버전 번호가 표시되어 있다. 
별도 옵션을 선택하는 것은 필요 없었다. 기본값인 [빠른 설치] 선택하고 [다음] 버튼으로 진행한다.
설치가 완료되고 나면 창을 닫는다.

 

CUDA 10.0 설치

 

2. cuDNN 복사
cuDNN 라이브러리는 압축 파일로 되어 있고
압축을 풀면 3개의 폴더가 나오고 그 안에는 각각 1개씩의 파일이 들어 있다.

일반적으로는 이들 파일은 CUDA 라이브러리가 설치된 폴더에 복사해서 덮어써야 하는데
어떤 블로그에서 덮어쓰는 것보다 복사해서 사용하는 것을 추천해서 나도 그렇게 했다.
덮어써도 될 수 있기는 한데
잘못될 경우를 대비해서 원본 파일을 보관해야 하는데
그것보다 cuDNN 폴더를 복사하는 것이 훨씬 쉽다.

cuDNN 라이브러리는 CUDA 라이브러리와 똑같은 버전을 사용해야 한다.
CUDA가 10.0이니까 cuDNN도 10.0을 사용한다.
압축을 푼 폴더에 가면 그 안에 cuda 폴더가 있고 그 안에 bin, include, lib 폴더가 있다.
cuda 폴더를 바탕화면에 복사한다.
이 시점에서 특별한 폴더를 찾을 생각하지 말고
설치에 성공하고 나면 원하는 폴더로 옮기는 것은 어렵지 않다.

 

3. 환경변수에 경로 추가
cuda 폴더를 덮어쓰지 않았기 때문에
cuDNN 파일들에 대한 경로를 환경에 추가해야 한다.

[내 PC] - [속성] - [고급 시스템 설정]으로 들어간다.

 

[환경 변수] - [사용자 변수] - [Path] - [편집]으로 들어가서
바탕화면에 추가된 bin, include, lib 폴더의 경로를 추가한다.

앞의 그림 중에 가운데 그림에 보면
[시스템 변수]에 CUDA_PATH 항목이 보이는데, CUDA 라이브러리 설치가 잘 되었다는 뜻이다.

 

4. 가상환경 생성
파이썬 가상환경을 반드시 만들어야 하는 것은 아니지만
잘 안될 수도 있고 CPU와 GPU 버전을 함께 사용해야 할 수도 있으니까.

커맨드 창을 연다. 관리자 모드로 열지 않아도 된다.
가상환경을 여러 번 구축해야 할 수도 있으니까 폴더 이름 뒤에 숫자를 붙였다.

가상환경 생성 및 진입

 

순서대로 아래 명령어를 입력한다.
python -m venv tf_1
cd tf_1
Scripts\activate.bat

 

5. 텐서플로 설치
activate 명령을 통해 가상환경으로 들어갔다.
여기서부터는 나만의 파이썬을 사용한다고 생각하면 된다.

텐서플로 GPU 버전을 설치하는 명령을 입력한다.
버전을 명시하지 않았기 때문에 현재 시점에서 최신 버전인 1.13.1을 설치한다.


pip install tensorflow-gpu

텐서플로 설치 중..

 

설치를 마치고 나면 노란색 글자가 보여 걱정이 된다.
pip 모듈을 업데이트하라는 말인데.. 텐서플로 GPU 버전하고는 상관없다. 하지 않아도 된다.

텐서플로 설치 완료

 

6. 설치 확인
python이라고 입력해서 파이썬 인터프리터를 구동한다.
갈매기(>>>) 프롬프트가 보이면 딱 한 줄만 입력하면 된다.

>>> import tensorflow

이 코드 한 줄에 대해 에러가 나지 않으면 성공이다.
그래픽 카드 버전을 뜻하는 빨간색의 메시지는 나타나지 않는다.
나중에 코드를 더 넣고 구동하게 되면 그때는 볼 수 있다.

이 한 줄의 코드로 안심이 되지 않는다면
파이참 등의 에디터를 통해 텐서플로 코드를 구동해 본다.
나는 mnist를 비롯해서 고양이와 강아지도 돌려봤다. 
너무 감격적이다.


많은 글을 읽었는데
성공의 핵심은 cuDNN 라이브러리를 바탕화면에 복사해서 사용한 것이 아닐까 생각한다.
별별정보님께서 올려놓은 [tensorflow gpu install - NVIDIA CUDA 설치하기]를 참고했다.

추가로 될거라고 생각했던 것도 있는데 CUPTI와 관련된 것이다.
텐서플로 공식 페이지에서 이 부분에 대해 언급했기 때문에 어떤 식으로든 설치에 관여할 것이라 생각했다.
[Windows 10에서 tensorflow-gpu 설치]에 설명되어 있고
혹시 앞의 과정에서 에러가 난다면 시도해 볼 수 있는 방법이다.

하나 더.
1.13 버전이 나온지 얼마 안되었기 때문에
설치 관련 글을 읽어보면 CUDA 9.0 또는 9.2 버전에 대한 얘기가 많다.
10.0 설치가 잘 되지 않았다면
굳이 10.0을 고집할 필요는 없어 보인다.

처음에 CUDA 9.0 버전 설치로 성공했는데
10.0 버전도 되는지 확인하고 싶었고 성공했다.
다만 9.0 버전을 설치할 때는 반드시 텐서플로 1.12 버전을 사용해야 한다.
텐서플로를 설치할 때 버전 번호를 명시하면 된다.

pip install tensorflow-gpu==1.12

수업을 해야 해서
내가 가장 많이 볼 거라고 생각하고 만들었다.
새벽 1시 30분.
졸려서 혹시 빠진 내용이 있을지도 모르겠다.
확인은 내일 하는 걸로.

 

5. 텐서플로 라이트 : 안드로이드 + AND 모델(텐서플로) (2)

어제 저녁부터 해서 밤 늦게까지 했고
오늘 아침부터 했는데.. 벌써 점심 먹을 때가 됐다.
하고 나면 별거 아닌데..
왜 이렇게 많은 시간이 걸리는 건지..

안드로이드 프로젝트의 이름은 LogicalAnd로 했다.
프로젝트 생성 및 환경 설정은 이전 글을 참고하자.

파이썬에서 만든 모델 파일 2개를 assets 폴더로 옮겼을려나..?
그리고 해야 할 것이
텐서플로 라이트 라이브러리 관련 코드를 build.gradle 파일에 추가하는 것을 잊어먹으면 안된다.
내가 만든 문서가 생각이 안나서
다시 보면서 프로젝트를 하고 있는데
build.gradle 파일에 코드 추가하는 것을 잊어먹어서 30분 날려먹었다.
애초 지금처럼 써놨어야 했는데..

xml 파일의 내용을 아래 코드로 대체한다.
이전 예제하고 비슷한데, 텍스트뷰의 높이를 높였고 버튼을 1개 줄였다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tv_output"
android:text="결과 출력"
android:textSize="23dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="200dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/button_1"
android:text="hx only"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_2"
android:text="hx multi"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_3"
android:text="hx + logic"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

</LinearLayout>
</LinearLayout>
</FrameLayout>


다음으로 MainActivity.java 파일을 아래 코드로 대체하자.
코드에 대한 설명은 주석에 충분히 했으니까.. 생략한다.

주의 깊게 봐야 할 부분은 입력과 출력 배열 구성이다.
무엇을 생각했건.. 그보다 복잡해서 고생했다.

package com.example.logicaland;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter;

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// hx 연산 1개만 처리 (and_model_for_hx_only.tflite 파일)
findViewById(R.id.button_1).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
// {{0}, {0}}는 파이썬에서의 [0, 0]과 같다.
// 이전 예제에서 보면 [0, 0]를 처리하기 위해 {{0}, {0}}를 사용했었다.
// 그래서 복잡하긴 하지만 3차원의 형태가 되어야 한다.
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};

// hx 연산은 0과 1 사이의 실수를 반환하기 때문에 float 타입으로 선언
float[][] output = new float[][]{{0}, {0}, {0}, {0}};

Interpreter tflite = getTfliteInterpreter("and_model_for_hx_only.tflite");
tflite.run(input, output);

// 마지막 매개변수는 button_3에서만 사용.
TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx single", output, null));
} catch (Exception e) {
e.printStackTrace();
}
}
});

// hx 연산 1개를 다중 출력으로 처리 (and_model_for_hx_only.tflite 파일)
findViewById(R.id.button_2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};
float[][] output = new float[][]{{0}, {0}, {0}, {0}};

// 3차원이 여러 개 있어야 하니까 4차원
float[][][][] inputs = new float[][][][]{input};

// 출력이 1개라서 요소도 1개만 넣는다.
java.util.Map<Integer, Object> outputs = new java.util.HashMap();
outputs.put(0, output);

Interpreter tflite = getTfliteInterpreter("and_model_for_hx_only.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx multi", output, null));
} catch (Exception e) {
e.printStackTrace();
}
}
});

// hx와 logics 연산 2개 처리 (and_model_for_hx_logics.tflite 파일)
findViewById(R.id.button_3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
// 자료형을 Object로 바꾸면 좋은데 makeOutputText 함수를 구성하기 어려워져서 생략.
float[][][] input = new float[][][]{{{0}, {0}}, {{0}, {1}}, {{1}, {0}}, {{1}, {1}}};
float[][] output_1 = new float[][]{{0}, {0}, {0}, {0}};
int[][] output_2 = new int[][]{{-1}, {-1}, {-1}, {-1}};

// 4차원은 복잡하니까 float[][][]를 Object 타입으로 처리하면 간편.
Object[] inputs = new Object[]{input};

// 출력 연산 개수에 맞게 출력 배열 추가 (hx와 logics 2개)
java.util.Map<Integer, Object> outputs = new java.util.HashMap();
outputs.put(0, output_1);
outputs.put(1, output_2);

// 이전 함수와 모델 파일이 달라졌다.
Interpreter tflite = getTfliteInterpreter("and_model_for_hx_logics.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

// makeOutputText 함수의 마지막 매개변수는 여기서만 사용. 이전 함수는 출력 결과가 하나뿐.
TextView tv_output = findViewById(R.id.tv_output);
tv_output.setText(makeOutputText("hx + logics", output_1, output_2));
} catch (Exception e) {
e.printStackTrace();
}
}
});
}

// output은 4행 1열. 4행이라는 것은 4개의 데이터에 대해 예측했다는 뜻.
private String makeOutputText(String title, float[][] output_1, int[][] output_2) {
String result = title + "\n";
for (int i = 0; i < output_1.length; i++)
result += String.valueOf(output_1[i][0]) + " : ";

// button_3에서만 사용하는 코드. 출력 2개는 button_3에만 있다.
if(output_2 != null) {
result += "\n";
for (int i = 0; i < output_2.length; i++)
result += String.valueOf(output_2[i][0]) + " : ";
}

return result;
}

private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


텐서플로 라이트는 왜 여러 개의 연산을 처리할 수 있는 쉬운 코드를 제공하지 않았을까?
내가 내린 결론은 의미가 없어서.

모델이라고 하는 것은
특정한 기능 하나를 수행하는 것을 전제로 한다.
회귀도 하고, 분류도 할 수 있는 모델이 의미가 있을까?
정답은 연속된 숫자 아니면 범주형 데이터이지.. 두 가지 모두가 될 수는 없다.

하나의 모델에 회귀와 분류를 같이 넣을 수 있을까?
있다!
학습을 각각 시키고 각각의 기능을 제공하는 연산을 출력으로 전달하면 된다.
그러나, 이렇게 했을 때
회귀 연산만을 추출해서 사용할 수 있는 방법은 없다.
안드로이드 API를 모두 살펴봤지만 그런 함수는 존재하지 않았다.
결국은 출력에 전달된 모든 연산을 수행하고 나서
필요한 출력 결과만 사용해야 하는데.. 너무 바보같은 짓이 되고 만다.

그렇지만 안드로이드 앱에서는 두 가지 기능을 모두 제공할 필요가 있다.
이걸 쉽게 처리하는 방법은
이번 예제에서처럼 회귀 모델과 분류 모델을 각각 제공하고
눌린 버튼에 따라 다른 모델을 사용하면 된다.
인터페이스도 깔끔해지고 사용하는 개발자도 쉽다.

에고.. 힘들었다.
논리 연산에 대한 원본 소스는 아래 사이트에서 가져왔다.
케라스 버전으로 되어 있는데, 함께 보면 좋겠다.

XOR 논리값 모델

XOR 안드로이드 앱

4. 텐서플로 라이트 : 안드로이드 + AND 모델(텐서플로) (1)

앞에서 안드로이드와 연동하는 기초를 꼼꼼하게 챙겼다고 생각했다.
너무 쉽게 생각한 것 아닌가 하는 생각이 들었다.

여기서는 AND 논리 연산을 모델로 꾸며서 안드로이드와 연동한다.
FC 레이어를 사용하는 진짜 딥러닝 모델이기 때문에
이번 예제를 처리할 수 있다면
그 어떤 모델이라도 연동할 수 있다고 생각한다.
그렇지만..
이번에도 나만의 착각일 수 있다.

이번 예제를 통해 공부하려고 했던 것은
모델에 여러 개의 연산이 존재할 때 안드로이드에서 개별적인 호출이 가능한지, 였다.
결론부터 말하자면
할 수는 있지만, 그래야 할 이유가 전혀 없다.

최종적인 출력은 다음과 같다.
얼핏 보면 같아보일 수 있는데.. 각각 해당 버튼을 누른 결과이다.
차이를 주기 위해 상단 제목을 모두 다르게 처리했다.


출력된 실수가 모두 같은 것은
학습이 끝난 모델에 동일한 데이터로 결과를 예측하기 때문이다.
마지막 화면은 시그모이드 연산과 시그모이드를 논리값으로 바꾸는 연산의 결과를 보여준다.

파이썬에서 모델 파일을 2개 만든다.
첫 번째는 출력이 하나일 때 사용하고, 두 번째는 출력이 두 개일 때 사용한다.
이번 코드에서 중요한 것은 입력이 복잡해졌고, 두 번째 출력을 담당하는 logics 연산이다.

import tensorflow as tf
import numpy as np


def make_model(model_path, sess, inputs, outputs):
# inputs와 outputs가 여러 요소를 갖는다면 다중 입출력이 된다.
converter = tf.lite.TFLiteConverter.from_session(sess, inputs, outputs)
flat_data = converter.convert()

with open(model_path, 'wb') as f:
f.write(flat_data)


# and 논리값.
# [0, 0] => [0]
# [1, 1] => [1]
x = [[0, 0], [0, 1], [1, 0], [1, 1]] # np로 변환하지 않아도 됨
y = np.float32([[0], [0], [0], [1]]) # 변환하지 않으면 z와 타입 불일치 에러

# 2와 1을 변수로 대체하면 지저분해지기 때문에 생략
# n_features, n_classes = len(x[0]), len(y[0]) # 2, 1

holder_x = tf.placeholder(tf.float32, shape=[None, 2])

w = tf.Variable(np.random.rand(2, 1), dtype=tf.float32)
b = tf.Variable(np.random.rand(1), dtype=tf.float32)

# (4, 1) = (4, 2) @ (2, 1)
z = tf.matmul(holder_x, w) + b # fully connected 레이어
hx = tf.nn.sigmoid(z) # 활성 함수

# 정수 형변환. 텐서플로 연산이기 떄문에 모델에 포함되고, 안드로이드에서 사용할 수 있다.
# hx는 실수 배열, logics는 정수 배열. 일부러 출력 결과를 다르게 만들었다.
logics = tf.cast(hx >= 0.5, dtype=tf.int32)

loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=z)
train = tf.train.GradientDescentOptimizer(0.1).minimize(loss)

with tf.Session() as sess:
sess.run(tf.global_variables_initializer())

for i in range(100):
sess.run(train, {holder_x: x})

# print(sess.run([hx, logics], {holder_x: x}))
# hx : [[0.04933239], [0.22989444], [0.24103162], [0.64626104]],
# logics : [[0], [0], [0], [1]]

# 출력 1개인 모델과 2개인 모델을 별도 생성
make_model('and_model_for_hx_only.tflite', sess, [holder_x], [hx])
make_model('and_model_for_hx_logics.tflite', sess, [holder_x], [hx, logics])


make_model 함수를 만들어서
첫 번째는 [hx], 두 번째는 [hx, logics]를 전달했고 파일 이름을 다르게 처리했다.

이번 글은 여기까지 하고
안드로이드 프로젝트는 다음 글에서 만들어 본다.

3. 텐서플로 라이트 : 안드로이드 기초(3)

안드로이드 기초의 마지막 부분이다.
시뮬레이터에서 앱을 구동해서 모델과 연동하는 마지막 코드까지 간다.


8. tflite 모델 파일을 로딩하고 run 함수를 호출해서 결과를 가져온다.
MainActivity.java 파일을 연다.
안드로이드가 익숙하지 않다면 아래 화면이 도움이 될 것이다. ^^


별 내용 없다.
파일 전체를 아래 코드로 대체한다.
붙여넣기를 할 때, 꼭대기에 패키지 이름이 있는데
여기서 설명하는 이름과 같지 않다면 첫 번째 줄은 복사하지 않는다.

package com.example.simplelite;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;

import org.tensorflow.lite.Interpreter; // 핵심 모듈

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// xml 파일에 정의된 TextView 객체 얻기
final TextView tv_output = findViewById(R.id.tv_output);

// R.id.button_1 : 첫 번째 버튼을 가리키는 id
// setOnClickListener : 버튼이 눌렸을 때 호출될 함수 설정
findViewById(R.id.button_1).setOnClickListener(new View.OnClickListener() {
// 리스너의 기능 중에서 클릭(single touch) 사용
@Override
public void onClick(View v) {
// input : 텐서플로 모델의 placeholder에 전달할 데이터(3)
// output: 텐서플로 모델로부터 결과를 넘겨받을 배열. 덮어쓰기 때문에 초기값은 의미없다.
int[] input = new int[]{3};
int[] output = new int[]{0}; // 15 = 3 * 5, out = x * 5

// 1번 모델을 해석할 인터프리터 생성
Interpreter tflite = getTfliteInterpreter("simple_1.tflite");

// 모델 구동.
// 정확하게는 from_session 함수의 output_tensors 매개변수에 전달된 연산 호출
tflite.run(input, output);

// 출력을 배열에 저장하기 때문에 0번째 요소를 가져와서 문자열로 변환
tv_output.setText(String.valueOf(output[0]));
}
});

findViewById(R.id.button_2).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 입력 데이터 2개 사용. [][]는 2차원 배열을 의미한다.
int[][] input = new int[][]{{3}, {7}};
int[] output = new int[]{0}; // 58 = (3 * 3) + (7 * 7), out = sum(x * x)

Interpreter tflite = getTfliteInterpreter("simple_2.tflite");
tflite.run(input, output);

tv_output.setText(String.valueOf(output[0]));

// 아래 코드는 에러.
// 텐서플로의 벡터 연산을 자바쪽에서 풀어서 계산해야 하는데,
// 구성 요소가 객체 형태로 존재하지 않을 경우 shape이 일치하지 않아서 발생하는 에러
// int[] input = new int[]{3, 7};
//
// 모델을 구성할 때 사용한 코드. x * x는 배열간의 연산이다.
// x = tf.placeholder(tf.int32, shape=[2])
// out = tf.reduce_sum(x * x)
}
});

findViewById(R.id.button_3).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 입력 변수를 별도로 2개 구성
int[] input_1 = new int[]{3};
int[] input_2 = new int[]{7};
int[][] inputs = new int[][]{input_1, input_2};

// 출력은 하나지만, 함수 매개변수를 맞추기 위해 맵 생성
java.util.Map<Integer, Object> outputs = new java.util.HashMap();

// 출력을 받아올 변수 1개 추가
int[] output_1 = new int[]{0}; // 10 = 3 + 7, out = x + y
outputs.put(0, output_1);

Interpreter tflite = getTfliteInterpreter("simple_3.tflite");

// 구동 함수는 run과 지금 이 함수밖에 없다.
// runForMultipleInputsOutputs 함수는 입력도 여럿, 출력도 여럿이다.
// 입력은 입력들의 배열, 출력은 <Integer, Object> 형태의 Map.
// key와 value에 해당하는 Integer와 Object 자료형은 변경할 수 없다.
tflite.runForMultipleInputsOutputs(inputs, outputs);

tv_output.setText(String.valueOf(output_1[0]));
}
});

findViewById(R.id.button_4).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 변수 2개를 전달하는 방법으로 앞에서처럼 해도 되지만 이번 코드가 간결하다.
int[][] inputs = new int[][]{{3}, {7}};

java.util.Map<Integer, Object> outputs = new java.util.HashMap();

// 별도 변수없이 직접 put 함수에 전달하면서 배열 생성
outputs.put(0, new int[]{0}); // 10, 21 = 3 + 7, 3 * 7 : out1, out2 = x + x, y * y
outputs.put(1, new int[]{0});

Interpreter tflite = getTfliteInterpreter("simple_4.tflite");
tflite.runForMultipleInputsOutputs(inputs, outputs);

// 별도로 출력 변수를 정의하지 않았기 때문에 Map 클래스의 get 함수를 통해 가져온다.
// Object 자료형을 배열로 변환해서 사용
int[] output_1 = (int[]) outputs.get(0);
int[] output_2 = (int[]) outputs.get(1);
tv_output.setText(String.valueOf(output_1[0]) + " : " + String.valueOf(output_2[0]));
}
});
}

// 모델 파일 인터프리터를 생성하는 공통 함수
// loadModelFile 함수에 예외가 포함되어 있기 때문에 반드시 try, catch 블록이 필요하다.
private Interpreter getTfliteInterpreter(String modelPath) {
try {
return new Interpreter(loadModelFile(MainActivity.this, modelPath));
}
catch (Exception e) {
e.printStackTrace();
}
return null;
}

// 모델을 읽어오는 함수로, 텐서플로 라이트 홈페이지에 있다.
// MappedByteBuffer 바이트 버퍼를 Interpreter 객체에 전달하면 모델 해석을 할 수 있다.
private MappedByteBuffer loadModelFile(Activity activity, String modelPath) throws IOException {
AssetFileDescriptor fileDescriptor = activity.getAssets().openFd(modelPath);
FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor());
FileChannel fileChannel = inputStream.getChannel();
long startOffset = fileDescriptor.getStartOffset();
long declaredLength = fileDescriptor.getDeclaredLength();
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength);
}
}


9. 안드로이드 화면을 구성하고 코드를 추가해서 결과를 표시한다.
화면 구성은 xml 파일에서 한다.
layout 폴더에서 activity_main.xml 파일을 연다.
아래와 같이 "Hello World"가 출력되는 단순한 미리보기가 나타난다.


아래 코드 전체를 activity_main.xml 파일에 덮어쓴다.
레이아웃과 컴포넌트는 안드로이드를 몰라도 대충 이해할 수 있기 때문에 설명은 생략한다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/tv_output"
android:text="결과 출력"
android:textSize="23dp"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="100dp" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">

<Button
android:id="@+id/button_1"
android:text="입력 1\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_2"
android:text="입력 2\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_3"
android:text="입력 1+1\n출력 1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

<Button
android:id="@+id/button_4"
android:text="입력 1+1\n출력 1+1"
android:textSize="19dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="100dp" />

</LinearLayout>
</LinearLayout>
</FrameLayout>


마지막으로 구동하고 나면
아래와 같은 최종 화면을 얻게 되고, 버튼을 누를 때마다 학습한 결과를 볼 수 있을 것이다.


2. 텐서플로 라이트 : 안드로이드 기초(2)

파이썬으로 해야 할 작업은 끝났다.
원래는 시행착오를 거쳐야 하기 때문에
파이썬과 안드로이드를 왔다갔다 하면서 짜증이 엄청 나야 한다.

이번에 할 작업은 안드로이트 프로젝트 구현 전반부이다.
안드로이드 프로젝트 생성부터 gradle 파일 수정까지 진행한다.

3. 안드로이드 프로젝트를 생성한다.
비어있는 프로젝트를 하나 만든다.


프로젝트 이름은 SimpleLite으로 한다.
프로젝트 폴더에 한글이 포함되면 경고가 뜬다. 이름만 입력하고 나머지는 그대로 둔다.

4. assets 폴더를 만들고 모델 파일을 붙여넣는다.
프로젝트를 만들면 assets 폴더가 존재하지 않는다.
폴더가 너무 많아서 위치를 정확하게 잘 찾아야 한다.
왼쪽 상단에 Project가 열려있는 것을 볼 수 있는데, 처음에는 Android라고 되어 있다.
Project로 변경하면 아래 화면을 볼 수 있고 main 폴더까지 가서 만들어야 한다.

이전 글에서 만든 4개의 모델 파일을 assets 폴더로 복사한다.


5. 텐서플로 라이트 모듈을 사용할 수 있도록 gradle 파일에 내용을 추가한다.

gradle 파일을 수정해야 하는데
그림에서 보는 것처럼 build.gradle 파일이 두 가지가 있다.
아래에 있는 Module 버전을 수정한다.


파일을 열어보면 dependencies 영역이 나오는데
텐서플로 라이트 모듈을 마지막에 있는 것처럼 추가한다.

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

implementation 'org.tensorflow:tensorflow-lite:+'
}


6. gradle 파일을 수정해서 모델 파일이 압축되지 않도록 한다.
android 영역을 찾아서 마지막에 aaptOptions를 추가한다.
제목에 있는 것처럼 압축을 방지한다.
메모리를 절약하기 위해 리소스를 압축하는데 그럴 경우 모델을 올바로 읽어들일 수가 없다.

android {
... 생략
aaptOptions {
noCompress "tflite"
}
}


7. gradle 파일을 수정했으니까 동기화를 진행한다.
파일을 수정하게 되면 파일 오른쪽 상단에 [Sync Now] 메뉴가 나타난다.
눌러주면 동기화를 진행하고 문제없으면 에러가 표시되지 않는다.

이번 글은 여기까지.

1. 텐서플로 라이트 : 안드로이드 기초(1)

첫 번째로 모델이라고 부를 수도 없는 모델을 안드로이드와 연동한다.
모델이 됐건 연산이 됐건
텐서플로 라이트 입장에선 다를 것이 없다.
너무 간단한 덧셈과 곱셈 연산을 통해
안드로이드로부터 플레이스 홀더 입력을 받아오는 것부터 해보자.

내용이 길어서 여러 개로 나누어서 작업한다.
여기서는 텐서플로 모델을 생성하고 tflite 파일로 변환하는 것까지 진행한다.

  1. PC에서 모델을 학습한다.
  2. 학습한 모델을 텐서플로 라이트 버전으로 변환한다.

안드로이드 앱을 구성하고 나면
최종적으로 아래와 같은 화면이 뜬다.
다양한 입력을 보여주기 위해 4가지 형태로 구성했다.
4개의 버튼 중에서 하나를 누르면 상단에 있는 텍스트뷰에 결과를 보여준다.


코드가 조금 긴데..
4가지 코드를 한번에 보여주기 때문에 그런 것뿐.. 하나하나는 가볍기 그지 없다.
model_common 함수를 중점적으로 봐야 하고
나머지 함수들에서는 입력과 출력이 어떻게 달라지는지를 봐야 한다.

import tensorflow as tf


# 이번 파일에서 공통으로 사용하는 함수.
# 컨버터 생성해서 파일로 저장. 다시 말해 모바일에서 사용할 수 있는 형태로 변환해서 저장.
def model_common(inputs, outputs, model_path):
# 텐서플로 API만을 사용해서 저장할 수 있음을 보여준다.
# 4가지 방법 중에서 가장 기본.
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())

# input_tensors: List of input tensors. Type and shape are computed using get_shape() and dtype.
# output_tensors: List of output tensors (only .name is used from this).
converter = tf.lite.TFLiteConverter.from_session(sess,
input_tensors=inputs,
output_tensors=outputs)
# 세션에 들어있는 모든 연산, 즉 모델 전체를 변환
# 반환값은 TFLite 형식의 Flatbuffer 또는 Graphviz 그래프
flat_data = converter.convert()

# 텍스트가 아니기 때문에 바이너리 형태로 저장. w(write), b(binary)
with open(model_path, 'wb') as f:
f.write(flat_data)


# 입력 1개, 출력 1개
def simple_model_1(model_path):
# 에러. 반드시 shape을 지정해야 함.
# x = tf.placeholder(tf.int32)

# 안드로이드에서 전달한 입력과 출력 변수가 플레이스 홀더와 연동
x = tf.placeholder(tf.int32, shape=[1])
out = x * 5

model_common([x], [out], model_path)

# 에러. 반드시 [] 형태로 전달해야 함.
# model_common(x, out, model_path)


# 입력 2개짜리 1개, 출력 1개
def simple_model_2(model_path):
x = tf.placeholder(tf.int32, shape=[2])
out = tf.reduce_sum(x * x)

model_common([x], [out], model_path)


# 입력 1개짜리 2개, 출력 1개
def simple_model_3(model_path):
# 에러. 반드시 shape을 지정해야 함.
# x1 = tf.placeholder(tf.int32, shape=[0])
# x2 = tf.placeholder(tf.int32, shape=[0])

x1 = tf.placeholder(tf.int32, shape=[1])
x2 = tf.placeholder(tf.int32, shape=[1])
out = tf.add(x1, x2)

# 입력에 2개 전달
model_common([x1, x2], [out], model_path)


# 입력 1개짜리 2개, 출력 1개짜리 2개
def simple_model_4(model_path):
x1 = tf.placeholder(tf.int32, shape=[1])
x2 = tf.placeholder(tf.int32, shape=[1])
out_1 = x1 + x2
out_2 = x1 * x2

# 입력에 2개, 출력에 2개 전달
model_common([x1, x2], [out_1, out_2], model_path)


simple_model_1('simple_1.tflite')
simple_model_2('simple_2.tflite')
simple_model_3('simple_3.tflite')
simple_model_4('simple_4.tflite')


tflite 파일로 변환하는 방법에는 4가지가 있는데
나는 쉬운 것만 쓰고 좀더 나을 수도 있겠지만 복잡해 보이는 방법은 사용하지 않을 것이다.

여기서는 가장 기본이 되는 텐서플로 코드를 직접 변환하는 방법을 사용했다.
세션에 들어있는 모든 데이터와 연산을 변환하게 되고 함수 이름은 from_session이다.
다른 방법이 궁금하면
도트(.)를 찍고 나면 어떤 방법이 있는지 이름을 통해 확인할 수 있다.

위의 코드를 실행하고 나면
현재 폴더에 tflite 파일 4개가 만들어진다.
생성된 모든 파일은 뒤에서 안드로이드 프로젝트로 복사해서 붙여넣는다.

PC 버전의 모델을 tflite 모델로 변환한다는 것은
모바일 버전에서 효율적으로 동작할 수 있도록 재구성하는 것이다.
PC 버전을 살짝 바꿔서 모바일 버전을 구현할 수도 있겠지만
그럴 경우 하드웨어 사양이 낮은 모바일에서는 결과 보기가 매우 힘들어진다.

convert 함수를 호출하면 모델을 FlatBuffer로 변환한다.
구글에서 게임 개발에 사용하기 위해 만들었는데
크로스 플랫폼에서 데이터를 효율적으로 처리하기 위한 라이브러리로 사용된다.



0. 텐서플로 라이트 : 소개

딥러닝을 강의하면서
꼭 해야지 하고 생각했던 것이 모바일과의 연동이었다.
정확하게는 스마트폰.
아이폰과 안드로이드 프로그래밍을 주로 했었으니까
딥러닝 모델을 모바일에 얹지 않고는 견디기 어려웠다.
놀기 바빠서 혹은 할게 많아서
이제서야 정리를 하게 됐다.
플러터를 다 하진 못했지만 어느 정도는 정리했으니까.
때가 됐다.

참고할 만한 자료가 많지 않아서 고생했다.
텐서플로에서 공개한 기본적인 내용만 소개하는 곳이 많았고
내가 원하는 모델을 올릴 수 있는 방법에 대한 설명은 찾지 못했다.
어쩌다 영문 사이트로부터 단서를 얻었고
그로부터 획득한 추가 정보들을 정리해 보려 한다.

텐서플로 라이트 공식 홈페이지가 있으니까 한번 가봐야 하지 않겠는가!


텐서플로 라이트는 텐서플로 모델을 모바일 환경에서 구동하도록 해준다.
모델 학습을 모바일에서 직접 하는 것은 아니고
학습된 모델을 모바일에 올려서 예측할 수 있도록 지원한다.

작업 순서는 다음과 같다.
여기서는 안드로이드 버전으로 진행하고 플러터 버전은 따로 보충한다.

  1. PC에서 모델을 학습한다.
  2. 학습한 모델을 텐서플로 라이트 버전으로 변환한다.
  3. 안드로이드 프로젝트를 생성한다.
  4. assets 폴더를 만들고 모델 파일을 붙여넣는다.
  5. 텐서플로 라이트 모듈을 사용할 수 있도록 gradle 파일에 내용을 추가한다.
  6. gradle 파일을 수정해서 모델 파일이 압축되지 않도록 한다.
  7. gradle 파일을 수정했으니까 동기화를 진행한다.
  8. tflite 모델 파일을 로딩하고 run 함수를 호출해서 결과를 가져온다.
  9. 안드로이드 화면을 구성하고 코드를 추가해서 결과를 표시한다.

역시.. 머리 속에 있을 때와는 달리
쓰고 나니까.. 순서가 꽤나 길다.
순서는 길지만 실제로는 어렵지 않다.

그래도 이 글을 읽는 사람은 알았으면 한다.
쉽게 정리하기까지 오래 걸렸다.

준비됐으면.. 가 보자!!

28. 플러터 : 서버/클라이언트 연동 (3)

이전 예제에서는 클라이언트에 해당하는 앱은 플러터로 구현하고
서버는 만들어져 있는 것을 가져다 사용했다.
이제 우리만의 서버를 직접 만들어서 연동하도록 하자.

이번 예제는 직접 만든 서버로부터 다양한 형태의 데이터를 수신한다.
데이터의 종류에는 문자열, 사진, 배열이 있고
스마트폰 앱에서 해당 버튼을 누르면 연결된 데이터만 보여주는 방식이다.

서버는 파이썬의 플라스크(flask)로 구현했다.
아쉽지만 여기는 파이썬하고는 상관 없기 때문에 이 부분은 간단하게 넘어가기로 한다.
그래도 우재는 할 수 있잖아?
아빠한테 다 배웠으니까..

로컬에서 서버를 구동했더니 로컬 호스트(127.0.0.1)를 안드로이드 에뮬레이터에서 인식을 못한다.
할 수 없이 파이썬애니웨어(pythonanywhere.com)의 도움을 받는다.
가입하면 무료로 웹 서버를 하나 구동할 수 있다. 지금처럼.
(현재 글을 작성할 때는 로컬 호스트에 대해서만 검증했고 로컬에서 동작하지 않았다.
수업을 준비하는 과정에서 다시 살펴봤고 사설 IP 주소로 동작하는 것을 확인했다.
그러나, 파이썬 애니웨어에 대해 알아두는 것도 나쁘지 않기 때문에 관련 내용을 유지한다.
사설 IP 주소를 사용하는 코드는 다음 번 글에서 확인할 수 있다.)


from flask import Flask
import json

app = Flask(__name__)


# 루트 인터페이스. 서버 주소(127.0.0.1:5000)의 루트 폴더.
@app.route('/')
def index():
return 'home. nothing.'


# 루트 아래의 string 폴더에 접근할 때 보여줄 내용
@app.route('/string')
def send_string():
return '취미 : 서핑, 스노보드, 배드민턴'


# 사진은 앱에서 파일에 직접 접근하기 때문에 이름만 알려주면 된다.
@app.route('/image')
def send_image():
return 'book.jpg'


# 배열이나 사전 등의 프로그래밍 언어 객체는 문자열로 변환해서 전달해야 하고
# json 또는 xml 등을 사용하게 된다. 여기서는 json 사용.
@app.route('/array')
def send_array():
items = [
{'id': 12, 'content': '세상은 호락호락하지 않다. 괜찮다. 나도 호락호락하지 않다.'},
{'id': 45, 'content': '공부를 많이 하면 공부가 늘고 걱정을 많이 하면 걱정이 는다.'},
{'id': 78, 'content': '참아야 한다고 배워 힘든 걸 참고 행복도 참게 되었다.'},
]
return json.dumps(items)


# 파이썬애니웨어(www.pythonanywhere.com)에서 구동할 때는
# 아래 코드를 사용해선 안 된다. 로컬에서 구동할 때만 사용한다.
# if __name__ == '__main__':
# app.run(debug=True)


파이썬애니웨어의 사용법은 해당 사이트에 가서 확인하자.

어려울 것이 전혀 없다.
프로젝트를 구성한 후에 static 폴더를 만들어서 'book.jpg' 파일을 하나 넣어둬야 아래 코드를 구동할 수 있다.

파이썬애니웨어 구성은 아래와 같은데.. 볼 건 없다.
왼쪽에 static과 templates 폴더가 있는데..
이번 예제에서는 templates 폴더는 사용하지 않는다.



이번에 만들 앱의 스크린샷이다.
화면이 4가지라서 캡쳐해서 깨끗하게 잘라내는 것도 힘들다.
첫 번째 사진은 서버로부터 아무 것도 가져오지 않았을 때를 가리킨다.



코드가 너무 길다.
150줄 정도 나왔는데.. 너무 많은 걸 보여주려고 한 것일까?

상수를 정의하기 위해 enum 클래스를 사용헀다. 
enum을 사용하지 않으면 당장은 편하지만 향후 불편할 수밖에 없다.
그리고 다트 문법도 익혀야 하는 관계로, 공부 차원에서 사용했다.

역시나 우재는 아래 코드 없이 화면만 보고 만들어 볼 수 있겠지?
파이썬애니웨어는 우재도 사용하고 있고.. 아빠보다 더 잘 하니까.
시간은 걸리겠지만..
고생하고 안 될 때만 살짝 보고. 아빠도 이번 코드는 진짜 오래 걸렸어.
아빠보다 빨리 하는 건 아니겠지?

참, 스냅샷의 connectionState 속성 사용하는 거 중요하니까.. 자세히 보고.
서버 구성은 클라이언트와 상관 없기 때문에 클라이언트 부분의 코드는 똑같을 수밖에 없다.
다만 서버쪽에서 어떤 방식으로 데이터를 전달하는지만 명확하게 하면 끝.
여기서는 사진을 제외하고는 모두 문자열로 처리.
수신 이후에 원하는 형태로 변환해서 사용하는 방식을 사용했다.


import 'dart:async';
import 'dart:convert'; // json 관련

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

// 수신 데이터의 종류를 상수로 설정
enum DataKind { NONE, STRING, IMAGE, ARRAY }

// 3가지 형태의 문자열 수신 : 단순 문자열, json 문자열, 이미지 주소 문자열.
Future<String> fetchString(DataKind kind) async {
if(kind == DataKind.NONE)
return '';

print(kind.toString());
print(kind.toString().split('.')[1]);
// 첫 번째 항목은 NONE. 비워두긴 했지만 루트(/)를 가리킨다는 뜻은 아니고, 사용 안함을 의미한다.
final details = ['', 'string', 'image', 'array'];
final urlPath = 'http://applekoong.pythonanywhere.com/' + details[kind.index];

// 아래처럼 구하면 details 배열과 동기화시키지 않아도 된다.
// 코드는 좀 복잡해지지만 업그레이드에서 유리할 수 있다.
// DataKind.IMAGE 상수를 toString 함수에 전달하면 'DataKind.IMAGE'가 나온다.
// final detail = kind.toString().split('.')[1].toLowerCase()
// final urlPath = 'http://applekoong.pythonanywhere.com/' + detail;

final response = await http.get(urlPath);

if (response.statusCode == 200)
return response.body;

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatefulWidget {
@override
State createState() {
return MyAppState();
}
}

class MyAppState extends State<MyApp> {
DataKind mKind = DataKind.NONE;

Widget makeChild(String str) {
switch (mKind) {
case DataKind.NONE:
return Placeholder(); // 아무 것도 수신하지 않았을 때 사용
case DataKind.STRING:
return Text(str, style: TextStyle(fontSize: 25),);
case DataKind.IMAGE:
return Image.network('http://applekoong.pythonanywhere.com/static/' + str);
default:
// str : 사전을 담고있는 배열 형태의 json 문자열.
final items = json.decode(str);
final textStyle = TextStyle(fontSize: 21, fontWeight: FontWeight.bold);

List<Container> widgets = [];
for(var item in items) {
widgets.add(
// 여백을 통해 위젯을 보기좋게 배치하기 위해 Container 사용
Container(
child: Column(
children: <Widget>[
Text(item['id'].toString(), style: textStyle),
Text(item['content'], style: textStyle, softWrap: true),
],
),
padding: EdgeInsets.all(20.0),
),
);
}
return Column(children: widgets);
}
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
FutureBuilder(
future: fetchString(mKind),
builder: (context, snapshot) {
// 이전 예제에서는 hasData를 사용했는데, 이전 호출의 결과가 남아있을 수 있다.
// builder에 전달되는 함수는 무조건 2회 호출된다. 처음에는 waiting으로, 두 번째에는 done으로.
// done이라면 완료되었다는 뜻이고 에러가 발생했을 수도 있다.
if (snapshot.connectionState == ConnectionState.done) {
// 나머지 공간 전체를 사용하는데, 전체 공간을 사용하지 않는 위젯이라면 가운데에 배치.
return Expanded(
child: Center(
child: makeChild(snapshot.data),
),
);
}
else if (snapshot.hasError) {
// 에러 메시지는 굳이 화면 가운데에 놓을 필요가 없다.
return Text('${snapshot.error}');
}

// 인디케이터가 다른 위젯들처럼 화면 가운데에 위치시킨다.
return Expanded(
child: Center(
child: CircularProgressIndicator(),
),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
RaisedButton(
child: Text('수신 안함'),
onPressed: () {
setState(() {
mKind = DataKind.NONE;
});
},
),
RaisedButton(
child: Text('문자열'),
// 정직하게 함수를 두 번 호출하는 코드
onPressed: () {
setState(() {
mKind = DataKind.STRING;
});
},
),
RaisedButton(
child: Text('사진'),
// 한 번은 정직하게, 한 번은 => 문법 사용
onPressed: () {
setState(() => mKind = DataKind.IMAGE);
},
),
RaisedButton(
child: Text('배열'),
// 두 번 모두 => 문법 사용. 간단하지만 가독성에서는 어렵다.
onPressed: () => setState(() => mKind = DataKind.ARRAY),
),
],
),
],
);
}
}


27. 플러터 : 서버/클라이언트 연동 (2)

어찌 된게 뭐하나 할 때마다 이렇게 힘이 드는 건지 모르겠다.
얼핏 보면 그냥 되야 하는데
막상 해보면 절대 그냥 되지 않는다.
이 간단한 걸 하는데 서너 시간 이상 들었다.
예전 같지 않은걸까..?

이전 글에서는 사용자 정보를 1개만 가져왔는데
이번에는 전체에 해당하는 10개를 가져와서 리스트뷰에 출력한다.
모든 데이터를 출력하면 지저분해지는 관계로 이름과 이메일만 보여준다.

이전 예제에서 데이터 하나만 보여주기 때문에 단조로운 화면이었다면
이번 예제는 배열을 보여주기 때문에 스크롤 기능을 제공하는 그럴 듯한 앱이라고 부를 수 있겠다.



우재야!
지금까지 했던 것처럼..
이번에도 아래 코드 안보고 위의 그림처럼 만들어 보자.
해보고 잘 안되면 살짝 보는 걸로.
우리 아들 화이팅!!


import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
// http가 있기 때문에 http.get()이라고 쓸 수 있다. 아니면 그냥 get(). 헷갈릴 수 있다.
import 'package:http/http.dart' as http;


void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

class User {
int userId;
String name;
String email;
String phone;
Map<String, dynamic> company;

User({this.userId, this.name, this.email, this.phone, this.company});
}

Future<List<User>> fetchUsers() async {
final response = await http.get('https://jsonplaceholder.typicode.com/users');

if (response.statusCode == 200) {
// 수신 데이터는 사전(Map)의 배열이지만, 정확한 형식은 Iterable 클래스.
// Map의 형식은 이전 예제에 나온 것처럼 Map<String, dynamic>이 된다.
final users = json.decode(response.body);

// Map을 User 객체로 변환.
// Iterable 객체로부터 전체 데이터에 대해 반복
List<User> usersMap = [];
for(var user in users) { // user는 Map<String, dynamic>
usersMap.add(User(
userId: user['id'],
name: user['name'],
email: user['email'],
phone: user['phone'],
company: user['company'],
));
}
return usersMap;
}

throw Exception('데이터 수신 실패!');
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Center 위젯이 없으면 데이터를 가져오는 동안 인디케이터가 좌상단에 표시된다.
return Center(
child: FutureBuilder(
future: fetchUsers(), // User 배열 반환
builder: (context, snapshot) {
if (snapshot.hasData) {
List<User> userArray = snapshot.data; // 정확한 형식으로 변환
return ListView.builder(
itemCount: userArray.length, // 필요한 개수만큼 아이템 생성
itemExtent: 100.0,
itemBuilder: (context, index) => makeRowItem(userArray[index], index),
);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}

// 데이터를 로딩하는 동안 표시되는 인디케이터
return CircularProgressIndicator();
},
),
);
}

// 리스트뷰의 항목 생성. idx는 항목의 색상을 달리 주기 위해.
Widget makeRowItem(User user, int idx) {
return Container(
child: Column(
children: <Widget>[
Text(user.name, style: TextStyle(fontSize: 21, color: Colors.white)),
Text(user.email, style: TextStyle(fontSize: 21, fontWeight: FontWeight.bold)),
],
),
padding: EdgeInsets.only(top: 20.0),
color: idx % 2 == 1 ? Colors.blueGrey : Colors.orange[300],
);
}
}


26. 플러터 : 서버/클라이언트 연동 (1)

플러터를 사용하려고 했던 최초의 목적은 서버와의 연동이었다.
딥러닝 서버를 구축하고
스마트폰 앱을 사용해서 결과를 보여주기 위해서.

첫 번째 시간으로 JSON 서비스를 제공하는 서버로부터 데이터를 가져오는 코드를 구성해 봤다.
좋은 코드가 있어 참고했음을 밝힌다.
이곳에서 참고한 코드를 확인할 수 있다.

최종적으로는 여러 개의 데이터를 리스트 형태로 보여준다.
원본 코드에서는 포스트(post)를 가져다 사용했는데
http에서 get과 post라는 단어가 핵심 용어로 사용되기 때문에 일부러 사용자(user)를 선택했다.

총 10개의 데이터 중에서 첫 번째 사용자의 정보는 아래와 같다.
참조했던 코드보다 훨씬 복잡하다. 사전(map) 안에 사전이 존재하는 형태다.

// 첫 번째 사용자 데이터
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
},


플러터와 관련된 json 내용은 이곳에서 확인할 수 있다.
fromJson과 toJson 함수를 만드는 방법을 비롯해서 유용한 코드가 많으니 꼭 살펴보기 바란다.
이번 코드에서는 fromJson 생성자만 만들어서 코드를 보기좋게 꾸민다.



서버와의 연동을 위한 처음 코드는 http 모듈을 프로젝트에서 사용할 수 있도록 연동하는 부분이다.
다트에서는 pubspec.yaml 파일에서 연동 작업을 한다.
pubspec.yaml 파일을 열고 http 모듈을 아래 코드처럼 추가한다.
콜론 오른쪽에 아무 숫자도 쓰지 않으면 최신 버전을 가져오라는 뜻이고
지금처럼 명시하면 해당 버전을 갖고 오라는 뜻이다.
^ 기호는 명시한 버전보다 높아야 함을 뜻한다.
추가된 코드는 http가 포함된 한 줄뿐이다.

dependencies:
flutter:
sdk: flutter

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
http: ^0.12.0


주석을 많이 붙이고
동일하게 동작하는 코드도 여럿 붙였더니.. 많이 길어졌다.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() => runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('서버/클라이언트')),
body: MyApp(),
))
);

class User {
final int userId;
final String name;
final String email;
final String phone;

// 사전 안에 포함된 사전. 디코딩을 했기 때문에 문자열이 아니라 사전(map)이 되어야 한다.
// 이때 사전의 값으로는 여러 가지가 올 수 있기 때문에 dynamic 키워드가 온다.
// 엣갈리면 앞에 나온 사용자 데이터에서 company 항목을 찾아서 확인해 볼 것.
final Map<String, dynamic> company;

User({this.userId, this.name, this.email, this.phone, this.company});

// fromJson 생성자. 이 함수를 호출하면 User 객체를 만들 수 있기 때문에 생성자라고 부른다.
// factory는 클래스 함수로 생성자를 만들 때 사용하는 키워드.
// 전역 함수처럼 동작하기 때문에 this 키워드를 사용할 수 없다.
factory User.fromJson(Map<String, dynamic> userMap) {
return User(
userId: userMap['id'],
name: userMap['name'],
email: userMap['email'],
phone: userMap['phone'],
company: userMap['company'],
);
}

// 위와 동일한 방법으로 factory 키워드를 생략할 수 있다.
// User.fromJson(Map<String, dynamic> userMap)
// : userId = userMap['id']
// , name = userMap['name']
// , email = userMap['email']
// , phone = userMap['phone']
// , company = userMap['company']
}

// json 서버로부터 사용자 데이터 중에서 첫 번째 데이터 1개만 가져옴
Future<User> fetchUser() async {
// 첫 번째를 가져오기 때문에 주소 마지막에 '1'이 붙어있다.
// http 프로토콜의 get 방식으로 데이터를 가져온다.
// get은 가져온다는 뜻이 아나리 어떤 방식으로 데이터를 가져올지를 알려주는 방식(method)을 의미한다.
final response = await http.get('https://jsonplaceholder.typicode.com/users/1');

// 웹 서버로부터 정상(200) 데이터 수신
if (response.statusCode == 200) {
// json 데이터를 수신해서 User 객체로 변환
final userMap = json.decode(response.body);
return User.fromJson(userMap);

// fromJson 생성자를 만들지 않고 직접 User 객체를 생성할 수도 있다.
// return User(
// userId: userMap['id'],
// name: userMap['name'],
// email: userMap['email'],
// phone: userMap['phone'],
// company: userMap['company']
//);
}

// ok가 아니라면 예외 발생.
// 실제 상황에서는 데이터 수신에 실패했을 때의 처리를 제공해야 한다.
// 다시 읽어야 한다던가 빈 데이터 또는 에러를 표시한다던가.
throw Exception('데이터 수신 실패!');
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final style = TextStyle(fontSize: 21, height: 2.0);
return Column(
children: <Widget>[
FutureBuilder(
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.hasData) {
// 변수에 저장할 필요없이 Text 위젯에 바로 전달해도 된다.
// userId는 int 자료형을 갖기 때문에 문자열 변환이 필요하다.
final userId = snapshot.data.userId.toString();
final name = snapshot.data.name;
final email = snapshot.data.email;
final phone = snapshot.data.phone;
final company = snapshot.data.company;

return Column(
children: <Widget>[
Center(child: Text(userId, style: style)),
Center(child: Text(name, style: style)),
Center(child: Text(email, style: style)),
Center(child: Text(phone, style: style)),
Center(child: Text(company['name'], style: style)),
Center(child: Text(company['catchPhrase'], style: style)),
Center(child: Text(company['bs'], style: style)),
],
);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
}

return CircularProgressIndicator();
},
),
],
);
}
}


CircularProgressIndicator 객체의 모양은 이곳에서 확인할 수 있다.
잘 동작하고 있다는 것을 보여줄 때 사용하는 인디케이터의 한 가지이다.

참.. json 서버의 역할을 너무 잘 수행해 주는 JSONPlaceHolder 사이트는 꼭 가봐야 한다.
홈 화면 아래에 내려가면 common으로 제공하는 api 목록을 확인할 수 있다.
앱에서 서버에 접근하기 전에
크롬 등의 브라우저를 사용해서 해당 주소가 잘 동작하는지 먼저 확인해야 한다. 꼭!



25. 플러터 : 텍스트 파일 읽기

플러터는 그만하려고 했는데..
경험 없는 사람들에게는 이런저런 것들도 필요하구나.. 하는 생각이 들었다.
우재, 너 말이야!

간단하게 텍스트 파일을 읽어서 출력할건데..
우재가 프로젝트에서 했던 것처럼 테이블로 표시해 볼께.

플러터 문서에 보면 순서가 정의되어 있는데..
실제로는 누락된 부분이 있어서 한번에 성공하지 못했다. (참고 사이트)


먼저 path_provider 플러그인을 설치하라고 되어 있다.
클릭하면 아래 페이지로 이동한다.


라이브러리를 추가하기 위해서는 pubspec.yaml 파일을 수정해야 한다.
dependencies: 항목을 찾아 다른 라이브러리하고 똑같은 형식으로 입력한다.

  path_provider: 0.5.0+1

pubspec.yaml 파일 상단에는 패키지 명령 몇 가지가 항상 표시된다.
그 중에서 get이나 upgrade 명령을 선택해서
path_provider 라이브러리를 프로젝트에 반영하면 준비 완료.


헐.. 지금까지 설명한 부분은 앱 내부에서 파일을 만들고 접근하는 방법.
미리 만들어 놓은 파일을 읽기 위해서는 사용할 수 없다.
모든 리소스는 패키지로 묶이기 때문에 앞의 코드로는 파일에 접근할 수 없고
플러터에서 제공하는 애셋 관리자를 사용해서 접근해야 한다.

지금 하려고 하는 것처럼 assets 폴더 아래에 파일을 만들었다면
앞의 코드를 사용할 수 없다는 말.
여기서는 '2016_GDP.txt' 파일을 사용한다.

2016_GDP.txt


pubspec.yaml 파일에 아래와 같이 접근할 수 있도록 명시한다.

dev_dependencies:
flutter_test:
sdk: flutter

flutter:
uses-material-design: true
assets:
- assets/2016_GDP.txt


이번 코드에서 가장 어려운 부분은 Future<String>과 FutureBuilder 클래스 사용법인데..
코드에 주석으로 달아놨으니까.. 읽어보고.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MaterialApp(home: MyApp()));

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('파일 읽기')),
body: SingleChildScrollView( // 수직 스크롤 지원
child: FutureBuilder(
future: loadAsset('assets/2016_GDP.txt'),
builder: (context, snapshot) {
// snapshot은 Future 클래스가 포장하고 있는 객체를 data 속성으로 전달
// Future<String>이기 때문에 data는 String이 된다.
final contents = snapshot.data.toString();

// 개행 단위로 분리
final rows = contents.split('\n');

var tableRows = <TableRow>[];
for(var row in rows) {
// 이번 파일에서 구분 문자는 콜론(:)
var cols = row.split(':');

// 마지막 줄은 빈 줄이라서 컬럼 개수가 3개가 아니다.
if(cols.length != 3)
continue;

// map 함수를 이용해서 문자열 각각에 대해 Text 위젯 생성
var widgets = cols.map((s) => Text(s));
tableRows.add(TableRow(children: widgets.toList()));
}
return Table(children: tableRows);
},
),
),
);
}

// assets 폴더 아래에 2016_GDP.txt 파일 있어야 함.
// AssetBundle 객체를 통해 리소스에 접근.
// DefaultAssetBundle 클래스 또는 미리 만들어 놓은 rootBundle 객체 사용.
// async는 비동기 함수, await는 비동기 작업이 종료될 때까지 기다린다는 뜻.
// 그러나, 함수 자체가 블록되지는 않고 예약 전달의 형태로 함수 반환됨.
// 따라서 Future 클래스를 사용하기 위해서는 FutureBuilder 등의 특별한 클래스가 필요함.
Future<String> loadAsset(String path) async {
return await rootBundle.loadString(path);
// return await DefaultAssetBundle.of(ctx).loadString('assets/2016_GDP.txt');
}
}


24. 플러터 : 목록 보기 (화면 전환)

여기까지만 할까?

왼쪽 그림에서 목록을 보여주고, 하나를 선택하면 오른쪽 그림으로 넘어가는거야.
상단 네비게이션바의 뒤로(<- 화살표) 버튼을 누르면 다시 왼쪽 그림이 나타나지.

왼쪽 그림에서 가운데 흐린 파랑은
출력 결과가 정확하게 가운데 표시된다는 것을 알려줘.
직접 해보면 가운데 오게 하는 것이 잘 안되거든.
왼쪽 그림에서는 표시가 잘 나진 않지만,
조금 스크롤되니까 위아래로 꼭 움직여 봐야 해.
안드로이드 디바이스를 어떤 걸 선택했느냐에 따라 화면 안에 전부 출력될 수도 있는데..
그러면 스크롤은 당연히 안 되겠지?



이번 코드에서 중요한 점은
첫 번째로 데이터 클래스를 만들어서 리스트를 생성하고 상세보기 클래스에 전달하는 방법.
두 번째로 위젯이 많이 중첩돼서 헷갈리지만, 출력물이 화면 가운데 오게 하는 방법.
세 번째로 커스텀 셀(custom cell)이라고 부르는 리스트뷰 항목을 원하는 대로 구성하는 방법.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: MyApp()));

// 구조체 스타일의 클래스. 상세보기로 데이터를 넘길 목적으로 생성.
class PageInfo {
PageInfo(this.image, this.title, this.group);

String image; // 사진 경로
String title; // 본문 설명
String group; // 지역 이름
}

class MyApp extends StatelessWidget {
final infos = [
PageInfo('images/family_1.jpg', '서핑 후의 달콤한 휴식. 아이서프 샵', '설악'),
PageInfo('images/family_2.jpg', '보드가 서퍼보다 크다!', '설악'),
PageInfo('images/family_3.jpg', '우재와 서진, 잘 생겼다!', '설악'),
PageInfo('images/family_4.jpg', '따뜻한 적이 없던 스키장. 장갑이 필요해!', '지산'),
];

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('새로운 기억들')),
// itemExtent 옵션으로 항목 높이 설정
// map 함수로 infos에 들어있는 개수만큼 ListTile 객체 생성 (makeRowItem)
body: ListView(
itemExtent: 120,
children: infos.map((info) => makeRowItem(context, info)).toList(),
),
);
}

Widget makeRowItem(BuildContext ctx, PageInfo info) {
return Center(child: Container(
color: Colors.lightBlue[100], // 출력물이 항목 가운데 오는지 확인하기 위한 용도
child: ListTile(
// 셀의 왼쪽 영역. 오른쪽을 가리키는 trailing도 있지만, 여기서는 사용 안함
leading: Image.asset(
info.image, width: 100, height: 100, fit: BoxFit.cover),
// 본문 영역에 Row나 Column 위젯을 통한 여러 개의 위젯 전달 가능
title: Row(
children: <Widget>[
Expanded( // 자식 위젯이 나머지 전체를 차지하도록 확장
child: Text(
info.title,
style: TextStyle(fontSize: 19, color: Colors.blueGrey)),
),
Container( // 답답해 보이지 않도록 패딩을 주기 위해 사용
child: Text(
info.group,
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black54)),
padding: EdgeInsets.only(left: 12.0, right: 12.0),
),
],
),
// 특정 셀을 선택하면 다음 화면으로 현재 셀의 데이터 전달하면서 이동
onTap: () {
Navigator.push(ctx,
MaterialPageRoute<void>(builder: (BuildContext context) => Detail(info: info))
);
},
),
),
);
}
}

// 상세보기 화면 클래스
// 실제로는 더 많은 데이터가 전달되어야 하지만, 여기서는 3가지만 사용.
class Detail extends StatelessWidget {
// key는 부모 클래스에서 사용하는 기본 옵션
Detail({Key key, this.info}) : super(key: key);
final PageInfo info;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(info.group)),
body: SingleChildScrollView( // 없으면, 화면을 벗어났을 때 볼 수 없음 (스크롤 지원)
child: Column(
children: <Widget>[
Container(
child: Center(
child: Text(
info.title,
style: TextStyle(fontSize: 21.0, color: Colors.black87),
),
),
padding: EdgeInsets.all(20.0),
),
Container(
child: Image.asset(info.image, fit: BoxFit.contain),
padding: EdgeInsets.all(10.0),
),
],
),
),
);
}
}


다 했어?
그러면 간단한 프로젝트를 시작해야 할테고
잘 안되는 것들은 꼭 질문하기.
명심할 것은 바로 질문해서는 안 되고 조금이라도 고민한 후에 질문하기.

우재가 시행착오를 잘 이겨내고
그럴듯한 스마트폰 앱으로 친구들한테 자랑할 날을 기다리면서.
아빠가!!

23. 플러터 : 목록 보기 (무제한)

무제한 목록보기를 구현했다.
제목으로는 끝이 없다는 뜻으로 '무한리필'!
쩔지 않니, 우재야?

왼쪽은 앱 실행 후 첫 번째 화면, 오른쪽은 여러 페이지를 이동한 후의 화면.
출력 문자열은 양쪽에 3글자 영어 단어가 있고, 가운데는 항목 순서를 표시했지.
단어 10개 중에서 난수로 뽑았기 때문에 중복될 수 있고, 양쪽 그림 모두에서 중복된 패턴이 보인다. ㅎ



이번 코드에서 중요한 것은
여러 페이지를 넘어설 만큼 개수가 많을 때 ListTile 객체를 생성하는 방법이야.
3개만 생성한다면 한번에 생성할 수도 있지만
너무 많아지면 계속해서 나열할 수는 없기 때문에
반복적으로 필요할 때마다 생성할 수 있는 구성이 필요해.

ListView 클래스의 builder 생성자를 사용해서 리스트뷰 위젯을 만들면서
itemBuilder 옵션으로 ListTile 객체를 생성하는 방법을 알려주는 게 핵심 중의 핵심!


import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(MaterialApp(home: MyApp()));

class MyApp extends StatelessWidget {
// 단어 목록으로부터 난수가 가리키는 위치의 단어를 보여주기 위한 변수 및 함수. 42는 seed.
final rand_gen = Random(42);
final words = ['ten', 'day', 'sky', 'fat', 'gym', 'run', 'ace', 'red', 'zen', 'sun'];

String randomWord() => words[rand_gen.nextInt(words.length)];
String capitalize(String s) => s[0].toUpperCase() + s.substring(1);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('무한리필')),
body: ListView.builder(
itemBuilder: (context, index) {
final first = capitalize(randomWord()); // 첫 글자가 대문자인 단어 생성
final second = capitalize(randomWord());
final disp_text = '$first $index $second';

return ListTile(
title: Center( // 항목 가운데 배치
child: Text(
'$first $index $second', // 원하는 형태로 조합
style: TextStyle( // 모든 단어를 같은 스타일로 처리
fontSize: 21,
fontWeight: FontWeight.bold,
),
),
),
onTap: () {
showDialog( // 초간단 경고창
context: context,
builder: (BuildContext ctx) => AlertDialog(title: Text(disp_text))
);
},
);
}
),
);
}
}


문자열에 포함된 단어 각각에 대해 다른 스타일을 적용하고 싶어졌어.
갑자기 그런 건 아니고
프로젝트를 하다 보면 그럴 경우가 많거든.
예전에는 문자열 3개를 Text 위젯으로 따로 만들어서 화면에서만 하나인 것처럼 했는데..
지금은 어렵지 않게 개별적인 스타일을 적용할 수 있게 라이브러리에서 지원을 하고 있어.

양쪽 영어 단어는 짙은 파랑, 가운데 순서는 빨강.
그리고 모든 단어는 폰트 크기를 키웠고 굵은 글씨로 수정했어.



앞쪽 코드에서 ListTile에 들어갈 title 옵션만 수정하면 돼.
TextSpan 위젯은 반드시 RichText 위젯의 자식으로 들어가야 하고
공통 스타일과 개별 스타일을 어떻게 적용하는지만 보면 돼.

참.. TextSpan 위젯 안에 TextSpan 위젯이 들어가는 거 중요하다!

title: Center(                          // 항목 가운데 배치
child: RichText(
text: TextSpan( // Text 확장
style: TextStyle( // 모든 자식에 대한 공통 스타일
fontSize: 21,
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(text: first + ' '), // 1번 자식
TextSpan( // 2번 자식
text: index.toString(),
style: TextStyle( // 개별 스타일
color: Colors.red
),
),
TextSpan(text: ' ' + second), // 3번 자식
],
),
),
),

22. 플러터 : 목록 보기 (리스트뷰 기본)

우재야.. 이제 플러터의 끝이 다가오고 있다.
아빠가 기본적인 내용을 열심히 만들기는 했는데
여기에 살을 붙여서 응용하는 것은 많이 힘들거야.
근데.. 그건 아빠가 해줄 수가 없어.
인생이란 늘 한결 같아서 고생하지 않으면 가질 수가 없었어.
마지막까지.. 파이팅!!

항목이 3개 있는 리스트뷰 위젯을 만들었다.
왼쪽은 처음 화면, 오른쪽은 '사진' 항목을 눌러서 경고창이 떴을 때의 화면.
내용이 별거 없는 만큼 리스트뷰가 동작하는 방식을 잘 보여줄 수 있게 만들었어.



ListView 클래스를 만드는 방법에는 여러 가지가 있지만
가장 쉬운 방법은 그냥 기본 생성자를 통해 만드는 거야.
리스트뷰 위젯 만들고 그 안에 필요한 만큼 리스트 항목, 여기서는 ListTile 클래스가 되지.

이번 코드에서는 개수가 많지 않기 때문에 코드가 선명하게 보이는 거고
다음 예제에서 항목의 개수를 늘려서 여러 페이지에 나타나도록 해 볼거야.

ListTile 클래스에서 가장 중요한 것은 onTap 매개변수.
나를 눌렀으니까 그에 따른 뭔가를 해야겠지.
여기서는 간단하게 항목별로 다른 문자열을 출력하고 있어.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: MyApp()));

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('목록 보기')),
body: ListView( // 1. 리스트뷰 생성하고
children: <Widget>[
ListTile( // 2. 리스트 항목 추가하면 끝!
leading: Icon(Icons.map),
title: Text('지도'),
onTap: () => _showDialog(context, '지도'),
),
ListTile(
leading: Icon(Icons.photo),
title: Text('사진'),
onTap: () => _showDialog(context, '사진'),
),
ListTile(
leading: Icon(Icons.phone),
title: Text('전화'),
enabled: false, // 비활성
onTap: () => _showDialog(context, '전화'),
),
],
),
);
}

// API에 있는 showDialog 함수와 이름이 같아서 밑줄(_) 접두사(private 함수)
void _showDialog(BuildContext context, String text) {
// 경고창을 보여주는 가장 흔한 방법.
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text('선택 완료!'),
content: Text('$text 항목을 선택했습니다.'),
// 주석으로 막아놓은 actions 매개변수도 확인해 볼 것.
// actions: <Widget>[
// FlatButton(child: Text('확인'), onPressed: () => Navigator.pop(context)),
// ],
);
}
);
}
}


showDialog 함수는 경고창을 표시하는 AlertDialog 위젯을 비롯해서
여러 가지 형태의 대화상자를 보여주는 가장 쉬운 방법이야.
showDialog 함수를 통하지 않으면 화면에 아무 것도 표시되지 않아.

21. 플러터 : 탭바

이번에는 가장 일반적인 형태로 사용하는 탭바 인터페이스를 살펴보자.

탭바 인터페이스의 구현은 TabController 클래스가 담당한다.
탭바는 보통 화면 하단에 위치한다.
사실 위쪽에 탭바가 있으면 '바(bar)'를 붙이지 않고 그냥 탭(tab)이라고 부른다.

탭바 인터페이스로 구현하는 이유는
탭에 연결된 화면이 완전히 달라지기 때문이다.
각각의 화면은 일부 연관되어 있을 수도 있지만, 전혀 상관없는 경우가 많다.
그래서 이번 예제에서도 단순하긴 하지만
완벽하게 다른 화면으로 각각의 화면을 구성했다.

탭바의 아이콘은 형식적으로 붙인 것이고
제목에 있는 색상에 맞게 화면 배경색을 처리했다.


각각의 화면은 보통 클래스로 구현을 한다.
색상에 맞게 Red, Green, Blue 클래스를 간단하게 만들었다.

배경색을 처리하기 위해 Container 클래스로 감쌌고
Card 위젯의 여백을 흉내내기 위해 maring 옵션도 일부 줬다.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: MyTabs()));

// TabController 객체를 멤버로 만들어서 상태를 유지하기 때문에 StatefulWidget 클래스 사용
class MyTabs extends StatefulWidget{
@override
MyTabsState createState() => MyTabsState();
}

// SingleTickerProviderStateMixin 클래스는 애니메이션을 처리하기 위한 헬퍼 클래스
// 상속에 포함시키지 않으면 탭바 컨트롤러를 생성할 수 없다.
// mixin은 다중 상속에서 코드를 재사용하기 위한 한 가지 방법으로 with 키워드와 함께 사용
class MyTabsState extends State<MyTabs> with SingleTickerProviderStateMixin {
// 컨트롤러는 TabBar와 TabBarView 객체를 생성할 때 직접 전달
TabController controller;

// 객체가 위젯 트리에 추가될 때 호출되는 함수. 즉, 그려지기 전에 탭바 컨트롤러 샛성.
@override
void initState(){
super.initState();

// SingleTickerProviderStateMixin를 상속 받아서
// vsync에 this 형태로 전달해야 애니메이션이 정상 처리된다.
controller = TabController(vsync: this, length: 3);
}

// initState 함수의 반대.
// 위젯 트리에서 제거되기 전에 호출. 멤버로 갖고 있는 컨트롤러부터 제거.
@override
void dispose(){
controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context){
return Scaffold(
appBar: AppBar(title: Text('교통 수단')),
body: TabBarView(
controller: controller, // 컨트롤러 연결
children: [Red(), Green(), Blue()]
),
bottomNavigationBar: Container(
child: TabBar(
controller: controller, // 컨트롤러 연결
tabs: [
// 아이콘은 글자 수 같은 걸로 선택. 의미 없음. 제목에 들어간 색상은 중요.
Tab(icon: Icon(Icons.card_travel), text: '빨강'),
Tab(icon: Icon(Icons.donut_small), text: '초록',),
Tab(icon: Icon(Icons.table_chart), text: '파랑'),
]
),
color: Colors.blueGrey,
),
);
}
}

// Card 위젯 구현
class Red extends StatelessWidget {
@override
Widget build(BuildContext context){
return Card(color: Colors.red);
}
}

// Text 위젯 구현
class Green extends StatelessWidget {
@override
Widget build(BuildContext context){
return Container(
child: Center(
child:Text('GREEN', style: TextStyle(fontSize: 31, color: Colors.white))
),
color: Colors.green,
margin: EdgeInsets.all(6.0),
);
}
}

// Icon 위젯 구현
class Blue extends StatelessWidget {
@override
Widget build(BuildContext context){
return Container(
child: Center(
child: Icon(Icons.table_chart, size: 150, color: Colors.white),
),
color: Colors.blue,
margin: EdgeInsets.all(6.0),
);
}
}


탭바 인터페이스는 각각의 화면을 개별적인 클래스로 처리하기 때문에
당연하게 개별 파일에 저장하게 된다.
앞의 예제를 다중 파일 형태로 수정해 보자.

1.
Red, Green, Blue 클래스를 잘라내서 my_screen.dart 파일을 만들어서 붙여넣자.
my_screen.dart 파일의 꼭대기에는 당연히 material.dart 파일 import문이 있어야 한다.

2.
main.dart 파일에 아래 코드를 추가한다.
다른 파일과 연동할 때는 아래처럼 사용한다. 점(dot)은 현재 폴더를 가리키는 문법이다.

import './my_screen.dart' as my_screen;


3.
TabBarView 객체를 생성하는 children 옵션을 아래처럼 수정한다.
참조하려는 클래스가 현재 파일이 아니라 my_screen 파일에 있다고 알려준다.

children: [
my_screen.Red(),
my_screen.Green(),
my_screen.Blue(),
]

20. 플러터 : 탭바 기본 (아이콘 + 제목)

탭바와 화면 중앙에 아이콘을 추가했더니
앱의 품질이 매우 좋아진 것처럼 느껴지는 것은 나만의 착각일까?



역시 주석을 꼼꼼하게 달았다.
이번 코드에서 주의 깊게 볼 것은 아이콘 사용은 아니다.
그건 너무 쉬우니까.

구조체 같은 Choice 클래스를 만들었고
왜 만들었는지에 대한 이해를 할 수 있다면 얼마나 좋을까?
주석에 있는 것처럼 단순하게 두 군데에서 사용하기 위해서라고 생각할까?

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: TabBarSample()));

// 아이콘과 제목을 함께 보여주기 위해 데이터만 포함하는 구조체 스타일의 클래스 생성
class Choice {
Choice(this.text, this.icon);
final String text;
final IconData icon;

// 매개변수를 전달할 때 {}가 있다면 매개변수 이름을 생략할 수 없다.
// Choice({this.title, this.icon});
}

class TabBarSample extends StatelessWidget {
// 탭바와 탭바뷰 양쪽에서 사용하기 위한 공통 데이터 리스트.
// Choice 생성자에 {}를 사용하지 않았기 때문에 매개변수 이름이 없다.
final choices = [
Choice('PLANE', Icons.flight),
Choice('CAR', Icons.directions_car),
Choice('BIKE', Icons.directions_bike),
Choice('BOAT', Icons.directions_boat),
Choice('BUS', Icons.directions_bus),
Choice('TRAIN', Icons.directions_railway),
Choice('WALK', Icons.directions_walk),
];

@override
Widget build(BuildContext context) {
return DefaultTabController(
length: choices.length,
child: Scaffold(
appBar: AppBar(
title: Text('교통 수단'),
bottom: TabBar(
tabs: choices.map((Choice choice) {
return Tab(
text: choice.text,
icon: Icon(choice.icon), // 이전 코드와 다른 부분
);
}).toList(),
isScrollable: true,
),
),
body: TabBarView(
// map과 toList 함수를 연결해서 화면 리스트 전달
children: choices.map((Choice choice) {
// 문자열과 아이콘을 모두 포함하는 위젯 객체 생성
// 이전 코드에서는 Text 위젯 하나만 사용했었다. 코드가 많아 클래스로 분리.
return ChoiceCard(
// 생성자에서 {}를 사용했기 때문에 text와 icon 매개변수 이름 사용 필수
text: choice.text,
icon: choice.icon, // 이전 코드와 다른 부분
);
}).toList(),
),
),
);
}
}

class ChoiceCard extends StatelessWidget {
// 매개변수 주변에 {}가 있기 때문에 text와 icon이라는 매개변수 이름을 함께 사용해야 한다.
const ChoiceCard({Key key, this.text, this.icon}) : super(key: key);

final String text;
final IconData icon;

@override
Widget build(BuildContext context) {
// 아이콘과 텍스트 양쪽에서 사용하기 위해 별도 변수로 처리
final TextStyle textStyle = Theme.of(context).textTheme.display3;
return Card(
child: Column(
children: <Widget>[
// 아이콘이 위쪽, 문자열이 아래쪽.
Icon(icon, size: 128.0, color: textStyle.color),
Text(text, style: textStyle),
],
mainAxisAlignment: MainAxisAlignment.center,
),
color: Colors.green,
margin: EdgeInsets.all(12),
);
}
}


클래스 없이 만들 수 있는 방법이 있지 않을까?
일단 클래스와 같은, 크기가 작아도 클래스니까 코드가 복잡해 진다.
클래스를 없애보자!

고생 엄청 했다.
몇 시간 코딩하고 나서 알았다.
생각 좀 하고 코딩했어야 하는데.. 일단 코딩하고 보는 성격이라..
우재야! 아빠가 급한 성격이니?

문제는 Card 클래스를 사용해서 화면 중앙에 탭의 내용을 다시 한번 출력하려는 것이다.
그러니 두 군데에 나올 수밖에 없다.
보통은 전혀 그럴 일이 없기 때문에 실제 상황에서는 Choice 같은 클래스는 사용하지 않을 것이다.
항상 그랬기 때문에 미처 감지하지 못했다. 늘 하던 방식으로 생각하는 바람에.

Card 클래스는 아래 그림과 같은
Material 클래스에서 제공하는 많지 않은 데이터를 보여주는 위젯이다.


이번 예제는 플러터에서 가져왔다.
좋은 예제들이 많으니까 꼭 둘러보기 바란다.

19. 플러터 : 탭바 기본

스마트폰에서 네비게이션은 정해진 순서에 따라
깊이를 타고, 다시 말해 안쪽으로 깊숙하게 들어가는 인터페이스를 말한다.
탭은 버튼처럼 누를 수 있는 영역이고
각각의 탭에는 자신만의 고유한 화면(페이지)이 연결되어 있다.
보고 싶은 화면의 탭을 눌러서 정해지지 않는 화면으로 이동하는 인터페이스가 탭바이다.

탭바의 위치는 상단이나 하단, 어느 쪽에도 연결할 수 있다.
첫 번째 예제로는 상단에 탭바를 연결했다.
왼쪽은 최초 실행한 모습이고, 오른쪽은 BOAT 탭을 눌렀을 때의 모습이다.
오른쪽 끝에 보면 보이지 않던 WALK 탭이 나타난 것을 알 수 있다.
탭의 갯수가 많을 경우 옵션에 따라 자동 스크롤 여부를 결정할 수 있다.



정말 단순한 코드.
멋대가리 없이 문자열로만 탭바를 구성한 코드.

탭바 인터페이스에서 중요한 것은 탭과 보여줄 화면의 연결.
컨트롤러의 역할은 다양하겠지만 이들을 자동 연결하는 것이 핵심.
DefaultTabController 클래스TabBarTabBarView 객체를 연결하는 가장 쉬운 방법 되겠다.

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: TabBarSample()));

class TabBarSample extends StatelessWidget {
final choices = ['PLANE', 'CAR', 'BIKE', 'BOAT', 'BUS', 'TRAIN', 'WALK'];

@override
Widget build(BuildContext context) {
// 가장 간단하고 쉽게 사용할 수 있는 기본 탭바 컨트롤러. 탭바와 탭바뷰 연결.
return DefaultTabController(
length: choices.length,
child: Scaffold(
appBar: AppBar(
title: Text('교통 수단'),
bottom: TabBar(
// map 함수는 리스트의 요소를 하나씩 전달한 결과로
// Iterable 객체를 생성하기 때문에 toList 함수로 변환
tabs: choices.map((String choice) {
// text는 탭바에 표시할 내용. 지금은 아이콘 없이 문자열만 사용.
return Tab(text: choice);
}).toList(),
isScrollable: true, // 많으면 자동 스크롤
),
),
// 탭바와 연결된 탣바뷰 생성.
// 탭바 코드와 똑같이 map 함수로 리스트 생성
body: TabBarView(
children: choices.map((String choice) {
return Center(
child: Text(
choice,
style: TextStyle(fontSize: 31),
),
);
}).toList(),
),
),
);
}
}

'플러터' 카테고리의 다른 글

21. 플러터 : 탭바  (0) 2019.02.11
20. 플러터 : 탭바 기본 (아이콘 + 제목)  (0) 2019.02.11
19. 플러터 : 탭바 기본  (0) 2019.02.11
18. 플러터 : 화면 이동(네비게이션)  (0) 2019.02.10
17. 플러터 : 로그인  (0) 2019.02.09
16. 플러터 : 텍스트 입력  (0) 2019.02.08

18. 플러터 : 화면 이동(네비게이션)

어휴.. 드디어 여기까지 왔다!
시간이 무한정 있는 것은 아니니까..
우재가 스마트폰 앱을 만들려면 최소 화면 전환까지는 해야 하고
추가로 목록 보여주기까지하면 퍼펙트!

일단 가장 간단한 네비게이션을 이용한 화면 전환부터 해보자.
스마트폰에서 네비게이션은 새롭게 표시할 화면을 기존 화면 위에 순서대로 쌓는 개념을 말해.
이때 화면은 영역 일부를 가리킬 수 없고 반드시 전체 화면이어야 하고.
그래서 이전에 있던 화면은 새로운 화면에 가려서 전혀 볼 수가 없게 되는거야.

왼쪽 화면(페이지)에서 버튼을 누르면 오른쪽 화면으로 이동하고,
"처음 화면으로 돌아가기" 버튼을 누르면 왼쪽 화면으로 돌아가게 만들었어.
Navigation 클래스를 사용한 네비게이션의 장점은
다음 화면으로 넘어갈 때는 버튼을 직접 구현해야 하지만,
이전 화면으로 돌아갈 때는 상단 제목줄 왼쪽에 있는 화살표 버튼을 눌러도 돼.
그러니까 오른쪽 화면에서 "처음 화면으로 돌아가기" 버튼은 만들지 않아도 되는거지.
그래도 나중에는 직접 구현해야 할 때가 있기 때문에 아빠는 구현을 한 거야.



아래 코드를 볼 때 중요한 점 첫 번째!
First와 Second 클래스는 거의 똑같은 코드라는 점.
First 위젯에서는 Second 위젯으로 이동할 거니까 다음 화면을 넣어야 하고
Second 위젯에서는 First 위젯으로, 다시 말해 Second 위젯을 제거할 거니까 현재 화면을 없애는 코드만 달라.

두 번째는 Scafold 클래스가 각각의 화면 클래스에 들어가야 해.
그래야 상단 제목줄부터 시작하는 전체 화면을 쉽게 구성할 수 있으니까.

세 번째는 두 번째와 같은 이유로
main 함수에서 Scafold 객체를 만들지 않아.
각각의 화면에 들어갔으니까 만들면 안되는 거지.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '네비게이션',
home: First(),
));
}

class First extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('첫 번째')),
body: Center(
child: RaisedButton(
child: Text('두 번째 화면으로 이동', style: TextStyle(fontSize: 21)),
color: Colors.blue,
onPressed: () {
// push에 전달되는 두 번째 매개변수는 Route<T> 클래스.
Navigator.push(context,
MaterialPageRoute<void>(builder: (BuildContext context) {
return Second();
})
);

// 화살표 문법 적용
// Navigator.push(context,
// MaterialPageRoute<void>(builder: (BuildContext context) => Second())
// );

// 위와 같은 코드. of 메소드 호출이 불편하다.
// Navigator.of(context).push(
// MaterialPageRoute<void>(builder: (BuildContext context) => Second())
// );
},
),
),
);
}
}

class Second extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('두 번째')),
body: Center(
child: RaisedButton(
child: Text('처음 화면으로 돌아가기', style: TextStyle(fontSize: 21)),
color: Colors.green,
onPressed: () {
Navigator.pop(context);

// 위와 같은 코드
// Navigator.of(context).pop();
},
),
),
);
}
}


17. 플러터 : 로그인

로그인이 뭐가 어려울까?
너무 쉽게 생각했고 코드를 구성하는 과정에서 많은 것을 배웠다.
우재, 너에게 다 알려주마!!

화면이 조금 이상해 보일 수도 있지만, 아빠가 의도한 화면이니까.. 이해하고.
아이디와 비밀번호를 입력 받아서 로그인 버튼을 눌렀을 때
일치하면 초기화를 시켜서 다시 입력을 받도록 했고
일치하지 않으면 스낵바를 통해 틀렸다고 알려주도록 했지.

계속 그랬던 것처럼
아빠 코드를 보지 않고 직접 구현할 수 있으면 좋겠는데..
어쩔 수 없어 코드를 보고 이해했다면
나중에라도 이번 화면을 직접 구현해 봐야 해. 알았지?



설명할 게 너무 많아서 코드에 주석을 달아야 했다.
중복되는 코드가 많아서 기본 함수인 makeText와 makeTextField를 만들었고
이들 함수를 사용하는 makeRowContainer 함수까지 총 3개의 함수를 사용했다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '로그인',
home: Scaffold(
appBar: AppBar(title: Text('로그인')),
body: Login(),
),
));
}

class Login extends StatefulWidget {
@override
State createState() => LoginState();
}

class LoginState extends State<Login> {
String userName = '';
String password = '';

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: <Widget>[
makeRowContainer('아이디', true),
makeRowContainer('비밀번호', false),
Container(child: RaisedButton(
child: Text('로그인', style: TextStyle(fontSize: 21)),
onPressed: () {
// 사용자 이름과 비밀번호가 일치한다면!
if(userName == 'dart' && password == 'flutter') {
// 세터로 초기화를 했기 때문에 build 함수 자동 호출하면서
// 아이디와 비밀번호 텍스트필드가 빈 문자열로 초기화된다.
setState(() {
userName = '';
password = '';
});
}
else
Scaffold.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('일치하지 않습니다!!')));
}
),
margin: EdgeInsets.only(top: 12),
),
],
mainAxisAlignment: MainAxisAlignment.center,
),
);
}

Widget makeRowContainer(String title, bool isUserName) {
return Container(
child: Row(
children: <Widget>[
makeText(title),
makeTextField(isUserName),
],
mainAxisAlignment: MainAxisAlignment.spaceBetween,
),
padding: EdgeInsets.only(left: 60, right: 60, top: 8, bottom: 8),
);
}

// Cascade 문법 사용. 주석으로 막은 코드보다 ..을 사용한 한 줄 코드가 훨씬 낫다.
// Cascade 문법은 아래에서 따로 설명한다.
Widget makeText(String title) {
// var paint = Paint();
// paint.color = Colors.green;

return Text(
title,
style: TextStyle(
fontSize: 21,
background: Paint()..color = Colors.green,
// background: paint,
),
);
}

Widget makeTextField(bool isUserName) {
// TextField 위젯의 크기를 변경하고 padding을 주려면 Container 위젯 필요.
// TextField 독자적으로는 할 수 없음.
return Container(
child: TextField(
// TextField 클래스는 입력 내용을 갖고 있지 않고, TextEditingController 클래스에 위임.
// 입력 내용에 접근할 때는 controller.text라고 쓰면 된다.
// 여기서는 로그인에 성공했을 때 초기화를 위한 용도로만 사용한다. 아래처럼 초기값을 줄 수도 있다.
// controller: TextEditingController()..text = '플러터',
controller: TextEditingController(),
style: TextStyle(fontSize: 21, color: Colors.black),
textAlign: TextAlign.center,
// 테두리 출력. enabledBorder 옵션을 사용하지 않으면 변경 불가.
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.red,
width: 2.0
),
),
contentPadding: EdgeInsets.all(12),
),
onChanged: (String str) {
// 입력이 변경될 때마다 갱신이 필요하지 않기 때문에 세터 사용 안함
// 아이디와 비밀번호 중에서 하나를 갱신한다.
if(isUserName)
userName = str;
else
password = str;
},
),
// TextField 위젯의 크기를 설정하려면 Container 위젯을 부모로 가져야 한다.
// 컨테이너의 크기가 텍스트필드의 크기가 된다.
width: 200,
padding: EdgeInsets.only(left: 16),
);
}
}


텍스트필드에 테두리를 넣는 코드도 힘들었고
아무리 해도 테두리 색상이 바뀌지 않는 것도 힘들었고.
모든 시행착오를 알려주고 싶은데.. 그건 우재가 직접 해야겠다.
너무 길어져서 알려줘도 알려주지 않은 것만 못하다.

앞에서 사용한 double dot(..) 문법은 다트에서 Cascade notation이라고 불러.
아래 두 개의 코드는 같은 코드이고, 그냥 봐서는 double dot의 장점이 없어 보이겠지?

// 1. 일반적인 코드
var button = querySelector('#confirm');
button.text = 'Confirm';
button.classes.add('important');

// 2. double dot을 사용해서 축약한 코드
querySelector('#confirm')
..text = 'Confirm'
..classes.add('important')


1번 코드는 별도의 변수를 선언해야 하고, 2번 코드는 선언이 필요없다.
변수를 선언한다는 것은 기존 코드와 유기적으로 연동할 수 없다는 것을 뜻해.
앞에 나온 메인 코드에서도 추가 변수를 사용한 불편한 코드를 주석으로 막아놓았다. 봤지?


왜 굳이 다른 방법을 찾는 것일까?
앞에서 보여준 것이 정석이라고 생각되지만, 가끔은 정석만으로 코딩할 수 없으니까.
플러터가 구성하는 위젯 트리에서 직접 텍스트필드를 찾으면 더 쉽지 않을까?

그래서 해봤는데.. 번거로운 것들이 너무 많아서 비추.
다만 아래 코드는 프로젝트 규모가 커졌을 때 활용할 수 있기 때문에 보여주는 걸로.

웃긴데.. 아래 코드 만드는데 4시간 정도 걸렸네.
다트에 대한 자료를 봐도.. 뭐가 뭔지 구분도 안 되고..
아직까지는 범용적으로 사용하는 언어가 아니라는 건 분명히 배웠다.

참, 아래 코드는 버튼의 onPressed 함수에 대해서만 보여준다.
나머지 코드에서는 세터 변수로 사용하는 userName과 password만 삭제하면 끝.

onPressed: () {
// 위젯 검색. TextField 위젯이 많을 경우 추가 검색 필요.
// byType 함수로 찾고, evaluate 함수로 찾은 위젯 반환.
// byType 함수의 반환값 자료형은 Finder 클래스. evaluate 함수는 Iterable<> 반환.
// 모든 위젯의 루트 클래스에 해당하는 Element 클래스가 요소의 타입이기 때문에 변환 필요.
var finds_1 = find.byType(TextField).evaluate();
var finds_2 = finds_1.cast<StatefulElement>();
// 런타임 객체가 위젯을 감싸고 있는 형태라서 widget 속성을 사용해서 실제 위젯을 가져옴
var finds_3 = finds_2.map((w) => w.widget);
// Iterable<> 자료형을 리스트로 변환해서 [0]과 같은 정수 인덱스 사용함
var finds_4 = finds_3.cast<TextField>().toList();

// 상위 클래스를 하위 클래스로 변환하는 다운캐스팅(downcasting)이기 때문에 에러. 업캐스팅만 가능.
// var finds_2 = finds_1 as List<StatefulElement>;
// var finds_4 = finds_3 as List<TextField>;

// 이전 코드에서는 userName이 문자열이었지만 여기서는 TextField 위젯
var userName = finds_4[0];
var password = finds_4[1];

// 텍스트필드의 입력 데이터에 접근하려면 controller 속성 사용
if(userName.controller.text == 'dart' && password.controller.text == 'flutter') {
// 입력 데이터를 직접 바꾸면 화면에서도 변경되기 때문에 세터(setState) 사용하지 않음
userName.controller.text = '';
password.controller.text = '';
}
else
Scaffold.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('일치하지 않습니다!!')));
}


find 객체는 플러터에서 제공하는 최상위 상수 객체로 미리 정의되어 있다.
사용하려면 아래처럼 import 추가할 것.
목적은 해당 객체가 위젯 트리에 잘 들어갔는지 검사(test)하기 위한 용도로 제작된 것처럼 보인다.
아마도 겸사겸사 만들었겠지..

import 'package:flutter_test/flutter_test.dart';

'플러터' 카테고리의 다른 글

19. 플러터 : 탭바 기본  (0) 2019.02.11
18. 플러터 : 화면 이동(네비게이션)  (0) 2019.02.10
17. 플러터 : 로그인  (0) 2019.02.09
16. 플러터 : 텍스트 입력  (0) 2019.02.08
15. 플러터 : 버튼 종류  (0) 2019.02.08
14. 플러터 : 버튼 + 사진  (0) 2019.02.07

16. 플러터 : 텍스트 입력

사용자로부터의 입력은 늘 액션을 동반하기 때문에 좀더 어렵다.
그래도 플러터라서 아이폰과 안드로이드에 비해서는 훨씬 쉽다.
다만 새로운 것을 익혀야 하는 것이 번거롭지만
해야 하기 때문에 하는 거라고 생각하자. 하지 않을 수 있는 방법이 없다.

TextField 클래스를 사용해서 입력 필드를 만들고
입력하는 글자를 아래쪽의 Text 위젯에 출력하는 코드를 구성했다.
키보드는 입력 초점이 TextField 위젯에 가면 자동으로 올라오니까 신경쓰지 않아도 된다.
입력 초점을 잃으면 자동으로 내려가는 것까지 포함된다.



버튼은 상태를 갖지 않는 StatelessWidget 클래스 계열이고
텍스트 필드와 같은 값이 바뀔 수 있는 것들은 StatefulWidget 클래스 계열이다.
StatefulWidget 클래스를 사용하기 때문에 State<> 클래스를 상속한 클래스까지 조금 번거로운 느낌이 든다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '텍스트 입력',
home: Scaffold(
appBar: AppBar(title: Text('텍스트 입력'),),
body: InputSample(),
),
));
}

class InputSample extends StatefulWidget {
@override
State createState() => InputSampleState();
}

class InputSampleState extends State<InputSample> {
String inputs = '';

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: <Widget>[
Container(
child: TextField(
style: TextStyle(fontSize: 32, color: Colors.red),
textAlign: TextAlign.center,
decoration: InputDecoration(hintText: '입력해 주세요'),
onChanged: (String str) {
setState(() => inputs = str);
},
),
padding: EdgeInsets.only(top: 10, bottom: 10),
width: 300,
),
Container(
child: Text(
inputs,
style: TextStyle(fontSize: 32),
textAlign: TextAlign.center,
),
padding: EdgeInsets.only(top: 10, bottom: 10),
width: 300,
),
],
mainAxisAlignment: MainAxisAlignment.center,
),
);
}
}


첫 번째 줄에 만든 Inputs 변수가 위젯들의 상태를 결정한다.
상태를 결정한다는 것은 위젯이 보여주려고 하는 상태(문자열)를 담고 있다는 뜻이다.
때문에 항상 동기화가 되어야 한다.
inputs 변수의 값과 위젯의 값이 다르면 사용자는 엄청난 혼란에 빠지게 되니까.

TextField 클래스의 옵션에 특별한 것이 일부 있다.
InputDecoration 클래스를 통해 입력해야 할 내용을 설명할 수 있고
입력한 내용이 바뀔 때마다 자동으로 호출되는 onChanged 매개변수가 있다.
자동으로 build 함수를 호출해서 매번 재구성해야 하기 때문에 세터(setState) 호출은 필수다.

'플러터' 카테고리의 다른 글

18. 플러터 : 화면 이동(네비게이션)  (0) 2019.02.10
17. 플러터 : 로그인  (0) 2019.02.09
16. 플러터 : 텍스트 입력  (0) 2019.02.08
15. 플러터 : 버튼 종류  (0) 2019.02.08
14. 플러터 : 버튼 + 사진  (0) 2019.02.07
13. 플러터 : 버튼 확장  (0) 2019.02.07

15. 플러터 : 버튼 종류

플러터에서 제공하는 수많은 종류의 버튼 중에서
많이 사용하는 버튼 일부를 이번에 보자.

해당 버튼 클래스의 이름을 제목으로 표시했다.
가장 많이 사용하는 RaisedButton, 버튼 눌림 기능이 없는 FlatButton,
상단 제목의 왼쪽이나 오른쪽에 주로 들어가는 IconButton(프린터),
안드로이드에서 주로 사용하는 공중에 떠있는 듯한 FloatingActionButton,
버튼은 아니지만 버튼처럼 사용할 수 있는 InkWell,
마지막으로 사진으로 만든 버튼까지.



버튼을 누르면  눌린 버튼의 클래스 이름을 하단에 스낵바 형태로 출력한다.
스낵바는 컨텍스트가 있어야 하고
StatelessWidget 클래스를 상속 받았기 때문에 별도로 저장을 하고 있다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼 종류',
home: Scaffold(
appBar: AppBar(title: Text('버튼 종류'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
BuildContext ctx;

@override
Widget build(BuildContext context) {
ctx = context;
return Center(
child: Column(
children: <Widget>[
RaisedButton(
child: Text('RaisedButton', style: TextStyle(fontSize: 24)),
onPressed: () => showMessage('RaisedButton'),
),
FlatButton(
child: Text('FlatButton', style: TextStyle(fontSize: 24)),
onPressed: () => showMessage('FlatButton'),
color: Colors.green,
textColor: Colors.white,
),
IconButton(
icon: Icon(Icons.print),
onPressed: () => showMessage('IconButton'),
),
FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => showMessage('FloatingActionButton'),
),
InkWell(
child: Text('InkWell', style: TextStyle(fontSize: 24)),
onTap: () => showMessage('InkWell'),
),
InkWell(
child: Image.asset('images/family_1.jpg', width: 120, height: 120),
onTap: () => showMessage('ImageButton'),
),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
);
}

void showMessage(String msg) {
final snackbar = SnackBar(content: Text(msg));

Scaffold.of(ctx)
..removeCurrentSnackBar()
..showSnackBar(snackbar);
}
}


코드에서 그렇게 눈여겨볼 것은 없어 보인다.
가능하면 같은 형태로 만들기 위해 child와 onPressed 매개변수만 처리했다.

FlatButton 위젯은 Text 위젯을 통해서 출력이 표현되는 것이 아니라
color와 textColor 매개변수가 별도로 존재하기 때문에 따로 색상을 주어서 처리했다.

InkWell 위젯은 사용자 입력을 받을 수 있는 사각형의 단순 영역으로
실제 버튼처럼 동작하지는 않기 때문에 onTap 매개변수가 존재한다.
1회 탭이 아니라 더블탭부터 여러 가지 제스처를 제공하기 때문에 활용범위가 매우 넓은 위젯이다.

사진 버튼은 정해진 클래스가 없다.
이번 코드에서는 InkWell 위젯을 사용해서 구현했는데
child 매개변수로 Image 위젯만 전달하면 어떤 버튼이든 사진을 표시할 수 있다.
다만 해당 버튼이 갖고 있는 특징 때문에 눌렀을 때의 효과같은 것들은 적절한지 직접 확인해야 한다.

'플러터' 카테고리의 다른 글

17. 플러터 : 로그인  (0) 2019.02.09
16. 플러터 : 텍스트 입력  (0) 2019.02.08
15. 플러터 : 버튼 종류  (0) 2019.02.08
14. 플러터 : 버튼 + 사진  (0) 2019.02.07
13. 플러터 : 버튼 확장  (0) 2019.02.07
12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07

14. 플러터 : 버튼 + 사진

예정에 없던 코드가 추가됐다.
예제란 것은 보는 사람의 동기를 부여할 수 있어야 하는데..
간단하게 구성할 때는 사진만한 것이 없다.

버튼 두 개로 사진을 번갈아 보여주는 코드를 만들었다.
우재한테 문제로 내면 좋을 것 같아서
어떤 어려운 부분이 있을까.. 검증하는 코드였는데..

이게 만드는 과정에서 매우 까다로웠다.
플러터의 특성 때문에
아이폰이나 안드로이드 앱에서 직접 코딩하는 것과는 많이 달랐다.
원했던 것은 화면에 있는 것처럼
사진이 전체 화면을 모두 덮는 것이었다.
잘 되지 않았고.. 시행착오를 많이 겪었다.
도전해 볼텐가?



이번 예제에서는 Stack 클래스를 사용했고
억지로 Align 클래스까지 사용을 했다. Center 클래스 친구라고 보면 된다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼 문제',
home: Scaffold(
appBar: AppBar(title: Text('버튼 문제'),),
body: Hobby(),
),
));
}

class Hobby extends StatefulWidget {
@override
State createState() {
return HobbyState();
}
}

class HobbyState extends State<Hobby> {
String selected = 'images/family_4.jpg';

@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
SizedBox.expand(
child: Image.asset(
selected,
fit: BoxFit.fill
),
),
Container(child:
Align(
child: Row(
children: <Widget>[
makeButton('산', () => selected='images/family_4.jpg'),
makeButton('바다', () => selected='images/family_2.jpg'),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
alignment: Alignment.bottomCenter,
),
padding: EdgeInsets.only(bottom: 50),
),
],
);
}

Widget makeButton(String title, VoidCallback callback) {
return RaisedButton(
child: Text(title),
onPressed: () {
setState(() => callback());
},
);
}
}


상태를 변경할 변수는 사진이 있는 경로이므로 문자열로 처리한다.
문제는 사진이 전체 화면에 꽉 차게 만들어야 하는데
width와 height 옵션을 주면 쉽지만, 자존심 때문에 그러고 싶지 않았다.

Positioned와 Expanded 클래스는 비슷하게 만들 수 있지만
이미지 위젯이 커지는 것이 아니라
이미지를 감싸고 있는 부모 위젯이 커지는 것이라서 화면 전체를 덮지 못한다.

부모하고 똑같이 만들고 싶다면
SizedBox 클래스로 크기를 설정할 때 expand 생성자를 사용하면 된다.

SizedBox 클래스를 사용한 두 번째 방법은
부모 클래스의 크기를 모르니까 엄청 크게 주는 것이다.

SizedBox(
child: Image.asset(
selected,
fit: BoxFit.fill
),
width: double.infinity,
height: double.infinity,
),

'플러터' 카테고리의 다른 글

16. 플러터 : 텍스트 입력  (0) 2019.02.08
15. 플러터 : 버튼 종류  (0) 2019.02.08
14. 플러터 : 버튼 + 사진  (0) 2019.02.07
13. 플러터 : 버튼 확장  (0) 2019.02.07
12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07
11. 플러터 : 버튼  (0) 2019.02.07

13. 플러터 : 버튼 확장

이제 버튼이 2개다.
2개를 만들 줄 알면 3개나 4개 만드는 것은 쉽다.
2개의 버튼을 사용해서 가운데 있는 텍스트의 값을 변경하는 예제다.
'더하기'를 누르면 값이 증가하고 '빼기'를 누르면 값이 감소한다.
할 수 있겠지?

새로운 코드가 추가되고 하기 보다는
앞에서 배웠던 것들을 잘 조합하는 문제이기 때문에
직접 해볼 것을 강력히 추천한다!!



클래스의 이름을 GameBoard로 수정했고
버튼 생성을 쉽게 하기 위해 makeButton 함수를 만들었다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼 확장',
home: Scaffold(
appBar: AppBar(title: Text('버튼 확장'),),
body: GameBoard(),
),
));
}

class GameBoard extends StatefulWidget {
@override
State createState() {
return GameBoardState();
}
}

class GameBoardState extends State<GameBoard> {
int currentValue = 0;

// 화살표(=>) 문법을 사용해서 한 줄짜리 함수 구성
// void addValue() => currentValue++;
// void subValue() => currentValue--;

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Container(
child: Text(
currentValue.toString(),
style: TextStyle(fontSize: 128),
),
padding: EdgeInsets.all(32),
),
Row(
children: <Widget>[
// makeButton('더하기', addValue),
// makeButton('빼기', subValue),

// 간단한 코드라서 함수를 따로 구성할 필요가 없다.
makeButton('더하기', () => currentValue++),
makeButton('빼기', () => currentValue--),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
),
],
mainAxisAlignment: MainAxisAlignment.center,
);
}

Widget makeButton(String title, VoidCallback callback) {
return RaisedButton(
child: Text(title),
onPressed: () {
setState(() {
callback();
});
},
);
}
}


가장 중요한 것은
setState 함수에 사용할 함수를 처리하는 부분인데
다트에는 => 문법이 있어서 한 줄짜리 함수를 축약해서 표현할 수 있고
한 줄이라서 함수 매개변수로 직접 사용할 수도 있다.
주석으로 막아놓은 부분을 대신할 수 있기 때문에 얼마나 편리한지 모르겠다.

함수가 어떻게 선언됐는지 알아야 할 때가 있다.
VoidCallback 함수라는 것을 찾으려면
onPressed 매개변수를 ctrl + 마우스 왼쪽 버튼으로 클릭하면 된다. (맥은 cmd)
그러면 매개변수가 정의된 곳으로 이동하고 해당 변수의 자료형을 만날 수 있다.
VoidCallback 자료형은 typedef 키워드를 사용해서 함수를 쉽게 사용할 수 있도록 재정의한 자료형이다.

혹시 눈치챘을려나?
우재는 정직해서 몰랐을 것 같은데..

setState 함수에 전달할 익명 함수도 한 줄이기 때문에 화살표 문법으로 대신할 수 있다.
이게 원래 해보기 전에는 긴가민가 하다.

Widget makeButton(String title, VoidCallback callback) {
return RaisedButton(
child: Text(title),
onPressed: () {
setState(() => callback());
},
);
}


'플러터' 카테고리의 다른 글

15. 플러터 : 버튼 종류  (0) 2019.02.08
14. 플러터 : 버튼 + 사진  (0) 2019.02.07
13. 플러터 : 버튼 확장  (0) 2019.02.07
12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07
11. 플러터 : 버튼  (0) 2019.02.07
10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03

12. 플러터 : 버튼 (Stateless vs. Stateful)

사용자가 액션을 취하고
그 액션에 대응해서 뭔가를 한다는 것은 역시 피곤하다.

이전 글에서 하려던..
버튼을 눌렀을 때 다른 색상으로 버튼 배경색을 변경해 보자.
버튼을 누를 때마다 검정색과 파랑색이 번갈아 나타나도록 처리했다.



계속해서 사용하던 MyApp 클래스 대신 MyButton 클래스를 만들었고
StatelessWidget 클래스 대신 StatefulWidget 클래스를 상속 받고 있다.
StatelessWidget 클래스는 build 함수에서 생성한 객체를 반환했지만
StatefulWidget 클래스는 createState 함수에서 생성한 객체를 반환하는 부분이 일단 가장 달라 보인다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼',
home: Scaffold(
appBar: AppBar(title: Text('버튼'),),
body: MyButton(),
),
));
}

class MyButton extends StatefulWidget {
@override
State createState() {
return MyButtonState();
}
}

class MyButtonState extends State<MyButton> {
var backColor = Colors.black;

@override
Widget build(BuildContext context) {
return Center(
child: RaisedButton(
child: Text('상태'),
onPressed: () {
// 에러는 아니지만, 배경색을 바꿀 수 없음
// backColor = Colors.lightBlue;

setState(() {
backColor = (backColor == Colors.black) ? Colors.lightBlue : Colors.black;
});
},
textColor: Colors.white,
color: backColor,
),
);
}
}


이전 글에서 color 속성이 final로 정의됐기 때문에 직접 변경할 수 없다는 것은 알았다.
플러터에서 제공하는 방법은 상태(state)를 통한 위젯 재구축(re-build)이다.
RaisedButton 클래스는 상태를 갖지 않는 StatelessWidget 클래스를 상속 받기 때문에
생성자를 통해서 필요한 값들을 전달하고 나면 메모리에서 삭제될 때까지 그 상태를 유지해야 한다.
새로운 상태를 주고 싶다면 없애고 다시 만드는 방법밖에 없다.

그런데 이전 글에서는 버튼 생성을 build 함수에서 하고 있기 때문에
결국 버튼을 다시 만드는 방법은 build 함수를 호출하는 것밖에 없는데.. 이게 어렵다.
그래서 우리 대신 build 함수를 대신 호출해 줄 수 있는 StatefulWidget 클래스를 사용해서 처리한다.
다시 말하지만 StatelessWidget 클래스의 build 함수는 최초 생성할 때 1회 호출되고 다시 호출할 수 없다.
다시 호출한다는 것은 새로운 객체를 만든다는 뜻이다.

StatefulWidget 클래스의 build 함수를 세터(setter)를 통해 자동 호출되도록 설계되었다.
세터를 정의하는 방법은 코드에서 보는 것처럼
onPressed 매개변수에 전달할 함수에서 setState 함수를 호출하면 된다.
그런데 setState 함수가 필요로 하는 매개변수가 함수이기 때문에 다시 함수를 정의한다.
이때 사용하는 함수는 매개변수도 없고 반환값도 없다.

setState 함수는 두 가지 역할을 하게 된다.
함수 호출을 했으니까 해당 함수가 해야할 세터로써의 역할과
매개변수로 전달된 함수를 호출하는 역할.
매개변수로 넘어온 함수를 호출해서 멤버변수의 값을 바꾸고
바뀐 값이 반영되도록 build 함수를 호출하는 것이 setState 함수가 하는 역할이다.

참.. StatefulWidget은 State 클래스와 연동해서 사용하도록 설계되었다.
크기가 작은 경우에는 기능을 하나로 합치는 것이 좋지만
프로젝트 규모가 커질 때를 생각하면 나뉘어 있는 것이 좋다.
객체의 상태를 유지하는 MyButtonState 클래스와
객체를 관리하는 MyButton 클래스가 그것이다.
문제는 해당 코드를 어떤 클래스에 넣는지가 어려운 데
이후 코드를 통해 정리해 나가도록 하자.

문제 하나 낸다.
버튼이 눌릴 때마다 "검정색"과 "파랑색" 글자로 버튼 제목을 변경해 보자.
기존 코드에 조금만 살을 붙여본다.

'플러터' 카테고리의 다른 글

14. 플러터 : 버튼 + 사진  (0) 2019.02.07
13. 플러터 : 버튼 확장  (0) 2019.02.07
12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07
11. 플러터 : 버튼  (0) 2019.02.07
10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03
9. 플러터 : 사진 배치  (0) 2019.02.03

11. 플러터 : 버튼

지금까지 얘기했던 것과 버튼은 액션이라는 점에서 다르다.
텍스트나 사진은 일반적으로는 보여주기만 하고
사용자로부터 입력을 받거나 하지는 않는다.

화면 중앙에 간단하게 버튼을 만들었다.
버튼의 종류에는 여러 가지가 있는데
가장 쉬운지는 모르겠지만, 가장 흔하게 볼 수 있는 푸시 버튼이다.
플러터에서는 RaisedButton 클래스가 푸시 버튼의 역할을 담당한다.



나중을 위해 글자 색과 배경 색 옵션을 추가했다.
버튼 안에 텍스트 위젯이 들어간다는 점이 신선할 수도 있지만
이렇게 해야 버튼 제목에 대해 할 수 있는 것이 많아지기 때문에
번거로울 수 있지만 굉장한 장점이다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼',
home: Scaffold(
appBar: AppBar(title: Text('버튼'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: RaisedButton(
child: Text('Click me!'),
onPressed: clickMe,
textColor: Colors.white,
color: Colors.black,
),
);
}

void clickMe() {
print('clicked!');
}
}

가장 중요한 것은 onPressed 매개변수로
버튼을 눌렀을 때 호출되는 함수와의 연결을 담당한다.
연결에 사용되는 함수의 형태는 매개변수도 없고 반환값도 없는 가장 단순한 형태이다.

버튼을 누를 때마다 안드로이드 창에 'clicked' 문자열이 출력되어야 한다.
혹시라도 아무 것도 나타나지 않는다면 뭔가 잘못된 것이다.
굿럭!


비슷하지만 다른 코드를  하나 더 준비했다.
버튼을 눌렀을 때 호출될 함수를 따로 구현하지 않고 바로 정의할 수 있다.
핫 리로드 기능을 사용해서 'clicked!'가 '눌림!'으로 바뀌는지 확인해 보자.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '버튼',
home: Scaffold(
appBar: AppBar(title: Text('버튼'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: RaisedButton(
child: Text('Click me!'),
onPressed: () {
print('눌림!');

// 배경색을 주황으로 바꾸고 싶지만, 에러!
// this.color = Colors.orange;
},
textColor: Colors.white,
color: Colors.black,
),
);
}
}

원래는 주석으로 설명을 달아놓은 것처럼
버튼 누를 때마다 버튼의 배경색을 변경하고 싶었다.
그런데 이 코드로는 할 수 없다.
가장 단순하고 직관적인 코드를 사용할 수 없어서 아쉽다.

에러가 나는 이유는
color 속성이 존재하기는 하지만 final로 정의했기 때문에 수정할 수 없다.
즉, 읽기 전용 속성이라고 보면 된다.
this는 현재 객체, 버튼이 눌렸으니까 버튼이 된다.


'플러터' 카테고리의 다른 글

13. 플러터 : 버튼 확장  (0) 2019.02.07
12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07
11. 플러터 : 버튼  (0) 2019.02.07
10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03
9. 플러터 : 사진 배치  (0) 2019.02.03
8. 플러터 : 사진 (2)  (0) 2019.02.02

10. 플러터 : 사진 옵션(BoxFit)

하루종일 도서관에 앉아 뭘 하는지 모르겠다는 생각이 든다.
정리하지 않고 나만 알아도 되는 것을 왜 이렇게 열심인지..
하루 열심히 해도 2개나 3개밖에 못 만드는데..
우재야.. 너 때문이다!

Image 클래스에 들어가는 fit 옵션에 대해서 살펴보자.
fit 매개변수에 전달되는 값은 BoxFit 클래스에 정의된 enum 상수.
아래 화면을 보면 조금씩 다르게 출력되는 것을 알 수 있다.
첫 번째 사진이 원본이니까 나머지는 원본과 비교해서 보면 된다.

  • 1번 : 원본. 가로세로 비율 변화 없음(contain)
  • 2번 : 지정한 영역을 꽉 채운다. 비율 변경됨. 가장 많이 사용하는 옵션 중의 하나(fill)
  • 3번 : 너비에 맞게 확대 또는 축소. 수평으로 크기 때문에 위아래 여백 발생(fitWidth)
  • 4번 : 높이에 맞게 확대 또는 축소. 수평으로 크기 때문에 수평 잘리는 영역 발생(fitHeight)
  • 5번 : 지정한 영역을 꽉 채운다. 비율 유지. 3번 또는 4번을 상황에 맞게 선택(cover)
  • 6번 : 원본 크기 유지. 원본으로부터 해당 영역 크기만큼 가운데를 출력. 기본 옵션(none)


import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진 옵션',
home: Scaffold(
appBar: AppBar(title: Text('사진 옵션'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(children: <Widget>[
Row(children: <Widget>[
makeImage(BoxFit.contain),
makeImage(BoxFit.fill),
]),
Row(children: <Widget>[
makeImage(BoxFit.fitWidth),
makeImage(BoxFit.fitHeight),
]),
Row(children: <Widget>[
makeImage(BoxFit.cover),
makeImage(BoxFit.none),
]),
]);
}

Widget makeImage(BoxFit option) {
return Container(
child: Image.asset('images/family_1.jpg', width: 200, height: 200, fit: option),
padding: EdgeInsets.only(left: 2, right: 2, bottom: 1),
);
}
}


fit 옵션을 사용하려면 명확하게 크기를 지정해야 한다.
화면을 꽉 채우고 각각의 사진을 구분할 수 있도록 크기는 200으로 하고 여백을 조금 줬다.

두 번째 코드를 보자.
행과 열을 한 번에 처리해주는 클래스가 있다.
GridView 클래스라고 하는데 일반적으로 count 생성자를 통해 객체를 생성한다.

첫 번째 코드와 출력만 놓고 보면 비슷하게 하려고 했기 때문에 구분이 어렵다.
자세히 보면 사진의 간격이 다르다.
이렇게 얘기해도 잘 모르겠지만.


코드가 훨씬 간단하다.
여백을 내부적으로 처리해주기 때문에 Container 객체로 감싸지 않아도 된다.
그래서 앞에서 사용했던 makeImage 함수를 없애고 Image 객체를 children 매개변수에 바로 전달했다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진 옵션',
home: Scaffold(
appBar: AppBar(title: Text('사진 옵션'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView.count(
padding: const EdgeInsets.all(5.0),
mainAxisSpacing: 5.0,
crossAxisSpacing: 10.0,
crossAxisCount: 2,
children: <Widget>[
Image.asset('images/family_1.jpg', fit: BoxFit.contain),
Image.asset('images/family_1.jpg', fit: BoxFit.fill),
Image.asset('images/family_1.jpg', fit: BoxFit.fitWidth),
Image.asset('images/family_1.jpg', fit: BoxFit.fitHeight),
Image.asset('images/family_1.jpg', fit: BoxFit.cover),
Image.asset('images/family_1.jpg', fit: BoxFit.none),
],
);
}
}


padding은 GridView 클래스 객체 전체에 적용되는 여백이다.
mainAxisSpacing은 수평 기준으로 자식 위젯을 떨어뜨려야 하는 간격이고
crossAxisSpacing은 수직 기준으로 자식 위젯간의 간격이다.
crossAxisCount 매개변수가 가장 중요한데 열(column)에 들어가는 자식 위젯의 갯수를 말한다.

'플러터' 카테고리의 다른 글

12. 플러터 : 버튼 (Stateless vs. Stateful)  (0) 2019.02.07
11. 플러터 : 버튼  (0) 2019.02.07
10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03
9. 플러터 : 사진 배치  (0) 2019.02.03
8. 플러터 : 사진 (2)  (0) 2019.02.02
7. 플러터 : 사진 (1)  (0) 2019.02.02

9. 플러터 : 사진 배치

여러 장의 사진을 다뤄보자.
앞에서 Row와 Column 위젯을 배웠으니까
아래 사진처럼 출력하는 것은 어렵지 않다고 생각할 것이다.
사용한 사진은 "7. 플러터 : 사진 (1)"에 들어있다.

그런데 내가 갖고 있는 4장의 사진은 크기가 다르다.
1번과 2번 사진의 크기가 같고 3번과 4번 사진의 크기가 같다.
화면에서는 1, 3, 4, 2 순서로 출력하고 있다.
그리고 사진 주변으로 약간의 여백을 줬다.


일반적인 방법을 사용한다면
출력할 사진의 영역을 구하기만 하면 된다.
이번 코드에서는 너비와 높이를 똑같이 줬고, 수평 크기를 절반으로 나눴기 때문에 너비(width)만 알면 된다.
화면의 크기는 MediaQuery 클래스를 통해 할 수 있다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진 배치',
home: Scaffold(
appBar: AppBar(title: Text('사진 배치'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width ~/ 2;
return Column(
children: <Widget>[
makeRow('images/family_1.jpg', 'images/family_3.jpg', width: width.toDouble()),
makeRow('images/family_4.jpg', 'images/family_2.jpg', width: width.toDouble()),
],
);
}

Widget makeRow(String leftPath, String ritePath, {double width}) {
return Row(
children: <Widget>[
Container(
child: Image.asset(leftPath, width: width-10, height: width-10),
padding: EdgeInsets.all(5.0),
),
Container(
child: Image.asset(ritePath, width: width-10, height: width-10),
padding: EdgeInsets.all(5.0),
),
],
);
}
}


~/ 연산은 정수 나눗셈을 수행한다. width 변수의 자료형은 int가 되고
makeRow 함수에 직접 전달할 수 없기 때문에 toDouble 함수로 형변환을 한다.

makeRow 함수에서 사진 2장을 수평으로 출력한다.
이때 4방향 모두에 대해 여백을 주고 싶기 때문에 margin 또는 padding 옵션이 필요하다.
문제는 Image 클래스에는 이런 옵션이 없다는 점.
그래서 Container 클래스로 감싸서 이 부분을 처리하게 된다.

그렇다면 출력된 사진의 실제 크기는 어떻게 될까?
패딩을 줬다면 패딩만큼 줄어든 크기가 되는 것인가?
아니다.
패딩은 Container 위젯에 줬기 때문에 Image 위젯과는 상관이 없다.
Image 위젯을 생성할 때 10을 빼지 않으면 화면을 벗어나기 때문에 원하는 결과를 얻지 못한다.
꼭 직접 확인해 볼 것.


두 번째 예제를 보자.
첫 번째 예제에서는 가로와 세로 크기가 같았지만, 이번에는 가로와 세로 비율이 달라질 수 있고
중요한 것은 화면을 꽉 채우고 싶다는 것이다.


쉽게 보면, 가로와 세로 크기를 알면 된다.
가로 크기는 앞의 예제에서 구했고 세로 크기만 구하면 된다.
두 장의 사진을 비교해서 적절한 비율을 찾은 다음에 가로(너비)에 곱하면 세로(높이)를 구할 수 있다.

그러나 최신의 언어와 도구를 사용하고 있다면
계산하지 않고도 구할 수 있는 방법이 있지 않을까?
Expanded와 IntrinsicHeight 클래스의 조합으로 그와 같은 일을 할 수 있다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진 배치',
home: Scaffold(
appBar: AppBar(title: Text('사진 배치'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
makeRow('images/family_1.jpg', 'images/family_3.jpg'),
makeRow('images/family_4.jpg', 'images/family_2.jpg'),
],
);
}

Widget makeRow(String leftPath, String rightPath) {
return IntrinsicHeight(
child: Row(
children: <Widget>[
makeExpandedImage(leftPath),
makeExpandedImage(rightPath),
],
crossAxisAlignment: CrossAxisAlignment.stretch,
),
);
}

Widget makeExpandedImage(String imagePath) {
return Expanded(
child: Container(
child: Image.asset(imagePath, fit: BoxFit.cover),
margin: EdgeInsets.all(5.0),
),
);
}
}


수평에 들어가는 모든 사진은 같은 너비를 가져야 한다.
사진 각각에 대해 Expanded 위젯으로 감싼 다음에 Row 또는 Column 위젯에 전달하면 된다.
이때 Image 객체의 크기를 여백(padding 또는 margin)만큼 줄여주지 않아도 된다.
수평에 들어가는 위젯의 대상이 Container이기 때문이고 사진은 Container 크기에서 margin만큼 뺀 크기로 자동 설정된다.

다만 이렇게 하면 Image 객체의 너비만 같고 높이는 같지 않게 된다.
CrossAxisAlignment 클래스(enum)의 stretch 옵션으로 수직으로 늘려주면 높이도 같아진다.
그러나, 어느 정도로 늘려줘야 하는지에 대한 기준이 없기 때문에 결과가 분명하게 나오지 않는다.
IntrinsicHeight 클래스는 자식 위젯이 갖고 있는 원래 크기에 맞게 자식 위젯의 크기를 설정한다.
이 말은 하위 자식 위젯들에 대해 Row 위젯과 같은 높이가 되도록 설정하는 것을 말한다.
역시 IntrinsicHeight 객체로 감싸지 않은 상태에서의 결과도 확인해 봐야 할 것이다.

'플러터' 카테고리의 다른 글

11. 플러터 : 버튼  (0) 2019.02.07
10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03
9. 플러터 : 사진 배치  (0) 2019.02.03
8. 플러터 : 사진 (2)  (0) 2019.02.02
7. 플러터 : 사진 (1)  (0) 2019.02.02
6. 플러터 : 텍스트 집합(Row + Column)  (0) 2019.02.02

8. 플러터 : 사진 (2)

앞에서 배운 코드에 약간의 옵션을 추가해 보자.
사진을 화면 가운데 오도록 했는데.. 어떻게 했는지 알겠지?

사진이 화면보다 크기 때문에
사진이 수평으로 꽉 차야 하는데.. 왼쪽과 오른쪽에 조금 여백이 있지?
그리고 사진 색상을 조금 수정했고.


출력 크기는 언제나 width와 height로 정할 수 있어.
이번 예제에서는 크기를 지정하지 않고 padding 옵션을 사용해도 되지만
width와 height 옵션은 사진 출력의 가장 기본이니까.

그런데 여기서 중요한 것은 크기를 모두 지정할 경우에는
가로와 세로의 비율이 달라질 수도 있다는 거야. 지금은 달라지지 않았지만.
가로와 세로 비율이 달라지면 많은 경우에 보기 싫은 사진이 되겠지?

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진',
home: Scaffold(
appBar: AppBar(title: Text('사진'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Image.asset(
'images/family_1.jpg',
width: 400,
height: 300,
color: Colors.red,
colorBlendMode: BlendMode.colorBurn,
),
);
}
}


color와 colorBlendMode는 그렇게 중요하진 않아.
여러 옵션이 있어서 이런 것도 있다는 걸 보여주려고 선택했을 뿐이다. 
colorBlendMode 옵션은 그림과 색상을 어떻게 조합할지 알려주는 역할을 해.

아래 사진을 보면 사진 비율이 달라진 것을 알 수 있어.
앞에서 얘기한 것처럼 원한다면 width와 height 옵션을 모두 설정하면 돼.


그리고 fit 옵션도 줘야 하지.
fit 옵션에 들어가는 데이터는 BoxFit 클래스(enum)의 값들.
fill을 비롯한 여러 옵션이 모두 중요하기 때문에 꼭 살펴봐야 한다.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진',
home: Scaffold(
appBar: AppBar(title: Text('사진'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Image.asset(
'images/family_1.jpg',
width: 400,
height: 100,
fit: BoxFit.fill,
color: Colors.red,
colorBlendMode: BlendMode.colorBurn,
),
);
}
}

'플러터' 카테고리의 다른 글

10. 플러터 : 사진 옵션(BoxFit)  (0) 2019.02.03
9. 플러터 : 사진 배치  (0) 2019.02.03
8. 플러터 : 사진 (2)  (0) 2019.02.02
7. 플러터 : 사진 (1)  (0) 2019.02.02
6. 플러터 : 텍스트 집합(Row + Column)  (0) 2019.02.02
5. 플러터 : 텍스트 집합 (Row, Column)  (0) 2019.02.02

7. 플러터 : 사진 (1)

문자열을 넘어 사진에 도전해 보자.
사진(이미지)을 출력하기 위해서는 준비가 필요해.
다트는 프로젝트에서 사용할 환경 설정을 pubspec.yaml 파일을 통해서 처리하는데
어떤 사진을 사용할 것인지 이 파일에 얘기를 해야 돼.

알려주는 방법에는 두 가지가 있어.
첫 번째는 해당 폴더 전체 사용. 두 번째는 지정한 파일만 사용.
우리가 놀러가서 찍었던 사진을 4장 준비했고,
family_1.jpg, family_2.jpg, family_3.jpg, family_4.jpg라고 이름 붙였어.

family.zip

사진이 얼마나 필요할지는 모르겠지만
앞으로 나오는 모든 코드에서 가능하면 4장까지만 사용하는 걸로.

pubspec.yaml 파일을 열고 아래처럼 수정하자.
첫 번째로 images 폴더의 모든 사진 추가.
참.. 프로젝트에 먼저 images 폴더를 만들어야 한다.
주의할 점은 assets라는 폴더는 따로 만들 필요없고,
반드시 pubspec.yaml 파일이 있는 곳에 만들어야 한다.

flutter:
uses-material-design: true
assets:
- images/


두 번째로 사용할 파일만 사용하는 방법.
아빠는 첫 번째가 편하기 때문에 앞의 코드를 사용하겠어.

flutter:
uses-material-design: true
assets:
- images/family_1.jpg
- images/family_2.jpg
- images/family_3.jpg
- images/family_4.jpg


pubspec.yaml 파일을 수정한 다음에는 파일 상단에 있는 Packages upgrade 메뉴를 눌러주는 것 잊지 말고.
새로운 패키지를 추가하는 경우에는 Packages get 메뉴를 사용할 때도 있고.
오른쪽에 보면 Flutter doctor가 있어서 콘솔에서 사용해야 하는 doctor 명령을 여기서 구동할 수도 있고.


어때, 잘 나왔지?
서핑 처음하러 갔을 땐가..?
실물보다 사진이 안 나와서 고를 만한게 잘 없었어.

사진 첫 번째 코드로 일단 그냥 출력하는 것부터 시작.


images 폴더 생성하고 사진 파일 붙여넣고 pubspec.yaml 파일 수정했다면
아래 코드에서 에러가 나진 않겠지?

Image 클래스에 있는 asset 생성자를 호출하면 끝.
여러 가지 옵션이 있지만 사진이 있는 경로만 전달하자.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '사진',
home: Scaffold(
appBar: AppBar(title: Text('사진'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Image.asset('images/family_1.jpg');
}
}


'플러터' 카테고리의 다른 글

9. 플러터 : 사진 배치  (0) 2019.02.03
8. 플러터 : 사진 (2)  (0) 2019.02.02
7. 플러터 : 사진 (1)  (0) 2019.02.02
6. 플러터 : 텍스트 집합(Row + Column)  (0) 2019.02.02
5. 플러터 : 텍스트 집합 (Row, Column)  (0) 2019.02.02
4. 플러터 : 텍스트 집합 (Stack)  (0) 2019.02.02

6. 플러터 : 텍스트 집합(Row + Column)

앱을 만들면서 얼마나 다양한 형태의 디자인을 하게 될 것인지..
Row와 Column에 대해 배웠으니까
이제는 두 개를 결합해서 사용하는 방법에 대해 보자.

우재야!
아래 화면처럼 만들 수 있겠니?
아빠 코드는 아래쪽에 있는데..
먼저 우재가 한번 만들어 보고 아빠꺼랑 비교하면 좋겠어.
도전해 볼까?!


이번 코드는 Row와 Column 클래스의 사용법이라기보다는
코딩에 대한 경험이 더 중요한 것 같아.
이런 문제에서 함수로 정리한다는 생각이 들지 않으면.. 음..

makeRow 함수를 만들었고, 텍스트 3개에 들어갈 문자열을 매개변수로 받았어.
그리고 Column 클래스 생성자에 전달해서 수직으로 배치.
행과 열 모두 균등하게 배치되어야 하니까 spaceEnvely 옵션을 모두 설정하면 끝.

import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
title: '멀티 텍스트',
home: Scaffold(
appBar: AppBar(title: Text('멀티 텍스트'),),
body: MyApp(),
),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
makeRow(left: '1', middle: '2', right: '3'),
makeRow(left: '4', middle: '5', right: '6'),
makeRow(left: '7', middle: '8', right: '9'),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
);
}

Widget makeRow({String left, String middle, String right}) {
return Row(
children: <Widget>[
makeText(left, width: 100, height: 100),
makeText(middle, width: 100, height: 100),
makeText(right, width: 100, height: 100),
],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
);

}

Widget makeText(String title, {double width, double height}) {
return Container(
child: Center(child: Text(title, style: TextStyle(fontSize: 23.0),),),
width: width,
height: height,
decoration: BoxDecoration(color: Colors.red[300]),
margin: EdgeInsets.all(10.0),
);
}
}


참.. 다트에서는 변수나 함수 정의할 때 밑줄 문자(_, underscore)를 사용하지 않아.
대신 카멜(camel) 표기법을 사용하지. 첫 글자는 소문자, 이후 단어의 첫 글자는 대문자. 알지?