state

개발/react 2020. 12. 11. 18:00 Posted by 아는 개발자

state는 리액트 컴포넌트 내에서 사용할 변수를 관리하는 역할을 한다. 예로 간단하게 정수 값을 표시하고 1씩 증가시키고 감소시키는 버튼이 있다고 하자. 아래와 같은 기능을 제공하는 앱이라면 컴포넌트중 누군가는 현재 화면에 표시되는 값을 들고 있어야 한다.

 

1. state 관리

 

이 값은 컴포넌트내의 state 변수에서 관리한다. 아래 코드를 보면 App 컴포넌트의 생성자에서 state를 만들고 그 안에 counter라는 값을 초기화 하는 것을 볼 수 있다. 그리고 render() 함수에서 현재 state 값을 참조해 counter 값을 보여주고 있다.

 

class App extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        };
    }

    render() {
        const title = '아는개발자'
        const subtitle = '이번에는 state를 공부해봅시다'
        const incrButton = '+1'
        const decrButton = '-1'
        return (
            <div>
                <Header title={title} subtitle={subtitle} />
                <Action buttonName={incrButton} />
                <Action buttonName={decrButton} />
                <p>현재 값: {this.state.counter}</p>
            </div>
        )
    }
}

 

2. state 변경 

 

state 값을 업데이트 할 때는 constructor 함수에서 처럼 this.state 값에 직접 업데이트 하는게 아니라 setState 함수를 사용한다. state 값이 업데이트 되면서 이 값을 참조하고 있는 ui도 동적으로 업데이트하기 위해서다. 이렇게 하지 않으면 state 값만 바뀌고 실제 화면은 그대로 남게된다. 

 

class App extends React.Component {
    constructor(props) {
        super(props);
        this.handleIncrease = this.handleIncrease.bind(this)
        this.handleDecrease = this.handleDecrease.bind(this)
        this.state = {
            counter: 0
        };
    }

    handleIncrease() {
        this.setState((prevState) => ({
            counter: prevState.counter + 1
        }))
    }
    handleDecrease() {
        this.setState((prevState) => {return {counter: prevState.counter - 1}})
    }

    render() {
        ...
        const incrButton = '+1'
        const decrButton = '-1'
        return (
            <div>
                ...
                <Action buttonName={incrButton} handleClick={this.handleIncrease} />
                <Action buttonName={decrButton} handleClick={this.handleDecrease} />
                <p>현재 값: {this.state.counter}</p>
            </div>
        )
    }

 

handleIncrease 함수와 handleDecrease 함수에서 setState 내부 구현부를 살짝 다르게 했다. handleIncrease처럼 간단하게 값을 업데이트할 수도 있고 handleDecrease처럼 return까지 포함해서 값을 업데이트 할 수도 있다.

 

3. 소스코드

https://github.com/kwony/react-study/blob/main/src/playground/blog-state.js

 

728x90

'개발 > react' 카테고리의 다른 글

webpack  (0) 2020.12.13
localStorage  (0) 2020.12.11
Props  (0) 2020.12.11
state  (0) 2020.12.11
babel  (0) 2020.12.06
arrow function  (0) 2020.12.06
TAG REACT, State

item decoration

개발/안드로이드 2020. 12. 6. 14:25 Posted by 아는 개발자

recycler view를 사용할 때 item 간의 간격을 다르게 주고 싶을 때가 있다. 예를 들어 a타입과 b타입의 아이템 사이의 간격은 상하 10dp, b타입과 c타입의 간격은 상하 5dp 이런식으로 설정하거나 더 보편적으로는 마지막 아이템인 경우에는 간격을 좀 더 띄워서 넣으려고 하는 경우가 있다. 이때 가장 빠르게 떠오르는 방법은 recyclerview의 adapter에서 position별로 margin을 주는 경우인데 이렇게 하면 안된다. recyclerview에서 자체적으로 position을 관리하기 때문에 내가 보고 있는 recyclerview에서 관리하고 있는 position이 다르다. 그래서 나는 분명히 제대로 준것 같은데 실제로 보면 다른 item에 margin이 들어간다. 이 부분이 크게 눈에 띄지 않는 부분이라 잘못 짜두고도 눈치채기가 어려워 종종 그냥 넘어가는데 나중에 디버깅해보면 item간의 간격이 내가 의도한 것과 다르게 표시된다. 그것도 아주 보기 싫게.

 

item간의 간격을 dynamic하게 조절할 때는 recycler view에서 관리하는 item decoration 라이브러리를 사용해야한다. 여기서 넘어오는 view는 recycler view에서 관리하고 있는 현재 item의 view다. 이 인자와 getChildAdapterPosition 함수를 이용해 현재 view item의 index를 찾을 수 있다. 이 정보와 outRect 인자를 활용해서 각 간격을 얼마나 줄 것인지 설정 할 수 있다.

 

recyclerview.addItemDecoration(object: RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)
        when (parent.getChildAdapterPosition(view)) {
            0 -> {
                outRect.left = DimensionUtils.dp2px(context, 20f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 10f).toInt()
            }
            listAdapter?.itemCount?: 1 - 1 -> {
                outRect.left = DimensionUtils.dp2px(context, 10f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 20f).toInt()
            }
            else -> {
                outRect.left = DimensionUtils.dp2px(context, 10f).toInt()
                outRect.right = DimensionUtils.dp2px(context, 10f).toInt()
            }
        }
    }
})

 

이제 잘못짠 코드들을 하나씩 수정해야겠다..

728x90

babel

개발/react 2020. 12. 6. 14:12 Posted by 아는 개발자

babel은 JFX로 작성된 react javascript 파일을 브라우저에서 인식할 수 있도록 변경해주는 컴파일러다. 예시로 아래처럼 생긴 코드를 브라우저에 렌더링 하려고 하면

 

const appRoot = document.getElementById('app');

function renderApp() {
    // JSX - JavaScript XML 
    var template = <div>
        <p>Hello react</p>
    </div>

    var appRoot = document.getElementById('app');
    ReactDOM.render(template, appRoot);
 }
 renderApp()

 

요런 에러가뜬다.

 

이건 JFX로 작성된 형태를 브라우저에서 읽을 수 없기 때문이다. react에서 기본적으로 만들어주는 프로젝트를 사용하면 이런 에러가 뜨지 않는데 처음부터 만들어가면 요런 에러를 보게 된다. babel 라이브러리를 이용하면 JFX로 작성한 언어를 컴파일해서 브라우저가 읽을 수 있는 형태로 바꿀 수 있다.

 

먼저 아래 명령어를 사용해서 babel을 설치한다.

 

npm install babel-cli

 

아래 명령어로 컴파일을 할 수 있다. 이 명령어의 뜻은 source 파일에 있는 build-it-visible.js 라는 파일을 babel로 컴파일해서 결과물을 public/scripts/app.js에 입력하라는 뜻이다. presets 옵션은 컴파일 옵션이고, watch는 build-it-visible.js 파일의 변화를 계속 관찰하겠다는 뜻이다. 저장하면 자동으로 컴파일을 해주므로 유용하다.

 

babel src/build-it-visible.js --out-file=public/scripts/app.js --presets=env,react --watch

 

그래서 아까 파일을 babel로 컴파일해주면 요렇게 바뀌게 된다.

 

'use strict';

var appRoot = document.getElementById('app');

function renderApp() {
    // JSX - JavaScript XML 
    var template = React.createElement(
        'div',
        null,
        React.createElement(
            'p',
            null,
            'Hello react'
        )
    );

    var appRoot = document.getElementById('app');
    ReactDOM.render(template, appRoot);
}
renderApp();

 

그리고 브라우저에서는 요렇게 잘 뜨게 된다.

 

 

사실 Babel은 인터프리터로 봐야한다.

728x90

'개발 > react' 카테고리의 다른 글

webpack  (0) 2020.12.13
localStorage  (0) 2020.12.11
Props  (0) 2020.12.11
state  (0) 2020.12.11
babel  (0) 2020.12.06
arrow function  (0) 2020.12.06

arrow function

개발/react 2020. 12. 6. 13:59 Posted by 아는 개발자

요즘 트렌드 언어 답게 react에서 사용하는 javascript도 arrow function으로 함수를 줄일 수 있다. 자바에서 사용하는 람다나 swift의 closure 랑 비슷하게 이름없는 함수(anonymous) 를 사용해서 1회성의 함수를 따로 선언하지 않고 삽입해서 쓸 수 있는 기능이다.

 

이 함수들은 모양새는 다르지만 모두 똑같이 제곱값을 리턴하는 함수다.

 

const square1 = function(x) {
    return x * x
};

const square2 = (x) => {
    return x * x;
};

const square3 = (x) => x * x;

 

객체 내에서 함수로 들어갈 때도 동일하게 줄일 수 있다. 

 

const user = {
    name: 'kwony',
    cities: ['pangyo', 'sinchon', 'madrid'],
    printPlacedLived: function() {
        return this.cities.map((city) => {
            return this.name + ' has lived in ' + city + '!';
        });
    },

    printPlacedLived2() {
        return this.cities.map((city) => {
            return this.name + ' has lived in ' + city + '!';
        });
    }
}

 

view 요소의 콜백 함수에도 arrow function을 적용할 수 있다. 아래 코드보면 첫번째 버튼은 arrow function을 적용해서 넣었고 두번째 버튼은 따로 만든 onClickButton 함수를 사용했다. 두개 모두 동일한 역할을 한다.

 

const onClickButton = () => {
    toggle.isVisible = !toggle.isVisible
    render()
}

const render = () => {
    const template = (
        <div>
        <button onClick={() => {
            toggle.isVisible = !toggle.isVisible
            render()
        }}>{toggle.isVisible ? 'hide detail' : 'show detail'}</button>
        <button onClick={onClickButton}>{toggle.isVisible ? 'hide detail' : 'show detail'}</button>

 

간단해보이지만 손에 익으려면 꽤 시간이 걸리니 이것도 놓치지 말고 꼼꼼히 연습해봐야겠다.

728x90

'개발 > react' 카테고리의 다른 글

webpack  (0) 2020.12.13
localStorage  (0) 2020.12.11
Props  (0) 2020.12.11
state  (0) 2020.12.11
babel  (0) 2020.12.06
arrow function  (0) 2020.12.06

Realm

개발/iOS 2020. 12. 5. 15:21 Posted by 아는 개발자

Realm은 오픈소스로 운영되는 모바일용 데이터베이스 클래스다. CoreData 처럼 관계형 데이터베이스를 읽고 쓰는 것을 지원하고있다. 개인적으로 CoreData를 사용하는 것 보다 훨씬 직관적이고 사용하기가 간편하다. 이번 포스트에서는 Realm을 Swift에 사용하는 방법을 간단히 정리해본다. 

 

1. Realm cocoapod 임포트

 

pod 'RealmSwift', '~> 4.4.1'

 

RealmSwift 라이브러리를 임포트시킨다. 몇 ios 버전에선 이전 Realm 버전으로 임포트하면 persmission 에러가 뜬다고 해서 4.4.1 버전으로 임의로 설정했다.

 

2. 클래스 설정 

 

import Foundation
import RealmSwift

class Category: Object {
    @objc dynamic var name: String = ""
    
    let items = List<Item>() 
}

 

Realm의 테이블로 추가할 클래스를 선언한다. Object 클래스를 상속해야하는데 이 클래스가 RealmSwift에 포함돼 있어야하는것을 유의하자. name은 Category 클래스의 이름에 해당하는 값이고 items는 Category 클래스와 연결된(relation) 클래스를 의미한다. Category와 Item이 1:N 의 관계가 될 예정이다.

 

import Foundation
import RealmSwift

class Item: Object {
    @objc dynamic var title: String = ""
    @objc dynamic var done: Bool = false
    @objc dynamic var dateCreated: Date?
    var parentCategory = LinkingObjects(fromType: Category.self, property: "items")
}

 

Category 클래스에 포함될 Item 클래스를 선언한다. 아래 parentCategory에 LinkingObjects 를 활용하면 쉽게 둘간의 관계를 정해줄 수 있다.

 

3. CRUD 

 

3.1 Create 

 

let newCategory = Category()
newCategory.name = textField.text!

save(category: newCategory)

func save(category: Category) {
    let realm = try! Realm()
    do {
        try realm.write {
            realm.add(category)
        }
    } catch {
        print("Error saving contet \(error)")
    }
    
    self.tableView.reloadData()
}

 

생성하는 작업은 CoreData랑 비슷하다. 임의의 Category 클래스를 만들고 realm 라이브러리로 추가해주는 작업을 하면 된다. realm.write 함수의 콜백 내에 add 함수를 추가하면 된다. 

 

if let currentCategory = self.selectedCategory {
    do {
        try self.realm.write {
            let realm = try! Realm()
            let newItem = Item()
            newItem.title = textField.text!
            newItem.done = false
            newItem.dateCreated = Date()
            currentCategory.items.append(newItem)
        }

 

Category에  포함된 Item의 경우는 약간 다르다. 아래 코드를 보면 Category처럼 add 함수로 추가하는게 아니라 포함되어있는 Category 클래스의 items 리스트에 append 시켜서 추가하고 있다. 서로 linking되어 있어서 그렇다.

 

3.2 READ 

 

func loadCategoreis() {
    let realm = try! Realm()
    self.categories = realm.objects(Category.self)
    
    self.tableView.reloadData()
}

 

읽어오는 작업은 realm 클래스에서 objects 함수에 읽어오려는 클래스를 추가하면 된다. 이러면 간단히 읽어올 수 있게 된다. 

 

3.3 UPDATE 

 

if let item = todoItems?[indexPath.row] {
    do {
        try realm.write {
            item.done = !item.done

 

realm으로부터 읽어온 클래스를 realm.write 콜백내에서 값을 수정하기만 하면 된다.

 

3.4. DELETE 

 

if let item = todoItems?[indexPath.row] {
    do {
        try realm.write {
            realm.delete(item)

 

수정과 마찬가지로 realm으로 읽어온 클래스를 realm.write 콜백내에서 삭제하면 된다.

 

4. 총평 

 

다른 개발자들도 CoreData가 정말 쓰기 불편했나보다. realm을 찬양하는 개발자들이 더 많다. 그리고 성능도 realm이 coredata에 비해서 압도적으로 훌륭하다. 물론 앱에서 데이터를 저장할 때는 이정도로 중요할것 같지는 않지만 말이다.

 

728x90

'개발 > iOS' 카테고리의 다른 글

Realm  (0) 2020.12.05
CoreData  (0) 2020.12.05
UserDefaults  (0) 2020.12.05
IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30

CoreData

개발/iOS 2020. 12. 5. 13:16 Posted by 아는 개발자

CoreData는 iOS 플랫폼 단에서 지원하는 관계형 데이터베이스 라이브러리다. XCode에서 지원하는 툴과 CoreData를 사용하면 SQL의 어려운 쿼리문을 사용하지 않고도 테이블을 만들고 데이터를 추가할 수 있다. 이번 포스트에서는 Xcode를 이용해 CoreData 를 초기화하고 기본 CRUD 작업을 수행하는 것을 다뤄보려고 한다.

 

1. Data Model 파일 생성 및 초기화

 

CoreData를 사용하려면 Data Model 타입의 파일을 하나 생성해야한다. New File을 클릭하고 필터로 data 를 입력해서 Data Model 타입의 파일을 하나 추가한다.

 

이 작업이 완료되면 AppDelegate.swift 파일에 아래 코드를 추가해야한다. 현재 앱에서 바라보고 있는 데이터 모델을 세팅해주고 앱이 꺼지기 전에 데이터를 저장하는 콜백을 등록하는 작업이다. 여기서 추가한 변수인 persistentContainer는 나중에 ViewController에서 여기서 선언한 변수를 사용할 예정이다. 

 

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
	... 
    
    func applicationWillTerminate(_ application: UIApplication) {
        // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
        self.saveContext()
    }

    lazy var persistentContainer: NSPersistentContainer = {
    
        let container = NSPersistentContainer(name: "DataModel")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()
    
    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }

 

2.  Entity, Attribute 추가 

 

아까 생성했던 DataModel 파일을 클릭해보면 왼쪽 하단에 Add Entitiy 라는 버튼이 있다. 현재 앱에서 관리할 테이블을 추가하는 과정이라고 봐도 될 것 같다. 클릭하면 Entity라는 이름으로 새로운 Entity가 생성된다. 용도에 맞게 이름을 변경해준다. 나는 Item이라고 정했다.

 

 

Entity를 만들고 나면 오른쪽 하단에 Add Attribute 버튼이 있는데 Entity가 가지게 될 속성을 추가하는 작업이다. SQL로 생각하면 테이블에 컬럼을 추가하는 것과 같다. 앱을 만들며 필요하다고 생각한 칼럼 값을 여기에 추가하면 된다. Item Entity에 name이라는 속성을 String 타입으로 추가했다. 오른쪽 패널에 이 속성에 특정 값들을 설정 할 수 있다. Optional이면 null을 허용한다는 뜻이고 Derived는 상속을 받는다? 인것 같다. 이거는 구상한 스키마에 따라서 결정하면 될 것 같다.

 

속성을 모두 업데이트 하면 Entity의 오른쪽 패널을 통해 Entity에 대한 속성을 설정 할 수 있다. Module은 Entity로부터 파생되는 Class가 사용되는 모듈을 설정하는데 특별한 경우가 아니면 현재 프로덕트 모듈(Current Product Module)로 변경해준다. Codegen은 Entity가 필요한 클래스를 자동 생성할지(Class Definition) 아니면 커스텀하게 생성할 지 (Category/Extension)을 결정하는 작업이다. Category/Extension을 사용하는 경우 직접 클래스를 만들어야하기 때문에 아직 CoreData를 공부하는 입장에서는 Class Defnition을 먼저 사용해보는게 좋다.

 

 

3. CRUD 

 

3.1 CREATE

 

DataModel 작업을 마치면 ViewController에서 아까 만든 Entity를 직접 데이터로 추가하는 작업이 남았다.  먼저 새로운 Entity를 추가하는 코드를 보자. 아래 코드는 Alert view에서 Item의 이름을 받아와 새로 추가하는 작업이다. 코드 중간부분을 보면 newItem 이라는 새로운 변수를 추가했는데 생성자로 사용한 클래스가 Item이다. 이 Item 클래스는 이전에 Data Model에서 추가한 Entity랑 이름이 같다. 이 생성자에서는 context라는 인자를 받는데 AppDelegate 클래스에서 선언한 persistentContainer 변수의 속성인 viewContext를 상용한다. 그다음 context.save() 함수를 호출해서 이 값을 통해 Item 변수를 새롭게 추가할 수 있다. 이것만 해주면 된다. SQL처럼 insert 어쩌고 저쩌고 쿼리를 날릴 필요가 없어서 간편하다.

 

@IBAction func addButtonPressed(_ sender: Any) {
    
    var textField = UITextField()
    
    let alert = UIAlertController(title: "Add New Todoey Item", message: "", preferredStyle: .alert)
    
    let action = UIAlertAction(title: "Add Item", style: .default) { (action) in
        // what will happend once user clicks the Add Item button on our UIAlert
        
        let container = (UIApplication.shared.delegate as! AppDelegate).persistentContainer
        let context = container.viewContext
        let newItem = Item(context: context)
        newItem.name = textField.text!
        self.itemArray.append(newItem)
        
        do {
            try context.save()
        } catch {
            print("Error saving contet \(error)")
        }

 

3.2 READ 

 

DataModel에서 만든 Entity에는 모두 fetchRequest() 라는 함수가 있는데 이 함수는 Entity에 해당하는 데이터를 읽어올 수 있는 select Request 쿼리를 갖고 있다. 따로 변수로 만들어서 생성 작업과 동일하게 context를 불러오고 fetch 함수 내에 request를 인자로 넣으면 데이터를 읽어올 수 있다.

func loadItems() {
    let request: NSFetchRequest<Item> = Item.fetchRequest()
    
    let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    
    do {
        itemArray = try context.fetch(request)
    } catch {
        print("error fetching data from context \(error)")
    }

 

3.3 UPDATE 

 

수정작업은 CoreData로부터 받아온 클래스의 속성 값을 수정하고 save를 호출하는 작업으로 이뤄진다. 이것 또한 복잡한 쿼리를 넣을 필요 없이 간단히 수정이 가능하다.

 

func updateItem(item: Item) {
    item.name = "update name"
    
    do {
        try context.save()
    } catch {
        print("error fetching data from context \(error)")
    }

 

3.4 DELETE 

 

삭제도 크게 다르지 않다. CoreData로부터 받아온 클래스를 context.delete의 인자로 넣어주고 save를 호출한다. 이렇게만 하면 데이터가 삭제된다.

 

func delete(item: Item) {
    context.delete(item)
    
    do {
        try context.save()
    } catch {
        print("error fetching data from context \(error)")
    }

 

4. 총평 

 

복잡한 쿼리를 짤 필요가 없어서 간편하긴 한데 Xcode 특유의, 마우스를 활용한 작업이 많아서 그런가 익숙해지려면 시간이 좀 걸릴 것 같은 느낌이다. 속성같은 것들은 키보드로 설정하는게 더 간편하고 직관적인데 말이다.

728x90

'개발 > iOS' 카테고리의 다른 글

Realm  (0) 2020.12.05
CoreData  (0) 2020.12.05
UserDefaults  (0) 2020.12.05
IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30

UserDefaults

개발/iOS 2020. 12. 5. 12:31 Posted by 아는 개발자

앱을 개발하다 보면 종종 단일의 데이터를 저장해야하는 경우가 생긴다. 예로 들면 어떤 가이드 화면을 보여줬는지 안보여줬는지 유무를 저장하는 Boolean 타입의 데이터나 영상의 음량을 미리 정해두는 Float 타입의 데이터값 같은 것들이 있다. 이런 데이터들은 관계형 데이터베이스로 저장하는 것 보다는 key - value로 저장하는게 효율적인데 iOS에서는 UserDefaults라는 라이브러리를 이용해 이 기능을 제공한다. 안드로이드를 경험한 개발자들은 SharedPreference 클래스와 비슷한 역할을 한다고 보면 될 것 같다. 사용하는 방법도 비슷하고 간편하다.

 

class TodoListViewController: UITableViewController {
    
    let defaults = UserDefaults.standard
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        defaults.setValue(false, forKey: "booleanKey")
        defaults.setValue(123, forKey: "integerKey")
        defaults.bool(forKey: "booleanKey")
        defaults.integer(forKey: "integerKey")

 

값을 업데이트 할 때는 setValue 함수로 value와 key값을 넣고 읽을 때는 불러오려는 데이터의 타입형의 함수에 key 값을 넣어서 호출한다. UserDefaults 함수로 초기화만 잘 해주면 돼서 사용하는데 큰 어려움은 없다.

728x90

'개발 > iOS' 카테고리의 다른 글

Realm  (0) 2020.12.05
CoreData  (0) 2020.12.05
UserDefaults  (0) 2020.12.05
IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30

IQKeyboardManager

개발/iOS 2020. 11. 30. 11:07 Posted by 아는 개발자

 

iOS에서도 소프트 키보드를 토글하면 UI 뷰 화면을 덮어버리는 문제가 있었다. 안드에서는 그래도 activity 단에서 키보드가 뜰때 어떻게 UI 레이아웃을 변형할지 어느정도 조정이 가능한데 ios에서는 그런게 특별히 없는 것 같다. 키보드가 뜰 때 마다 OS에 물어봐서 keyboard의 height를 알아오고 그에 맞춰서 View를 올려줘야 한다고 하는데.. 매번 하기엔 꽤 번거로운 일이다. 

 

다행히 삽질(?)을 미리 해두고 라이브러리 형태로 배포를 해뒀다고 한다. 라이브러리 이름은 IQKeyboardManager 이고 사용방법도 아주 간단하다. 먼저 cocoapod을 이용해 라이브러리를 임포트 하자.

 

platform :ios, '13.0'

target 'Flash Chat iOS13' do
  use_frameworks!

  pod 'IQKeyboardManagerSwift'

end

 

임포트후 AppDelegate라는 파일에 IQKeyboardManager 클래스를 호출해서 관련 속성을 세팅하자.

 

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        IQKeyboardManager.shared.enable = true
        IQKeyboardManager.shared.enableAutoToolbar = false
        IQKeyboardManager.shared.shouldResignOnTouchOutside = true
        
        return true
    }

 

이렇게만 하고 다시 실행하면 keyboard가 올라옴에 따라서 UI가 자동으로 이동하는 것을 확인 할 수 있다.

 

728x90

'개발 > iOS' 카테고리의 다른 글

CoreData  (0) 2020.12.05
UserDefaults  (0) 2020.12.05
IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30
tableview  (0) 2020.11.24

URLSession

개발/iOS 2020. 11. 30. 10:42 Posted by 아는 개발자

JAVA나 C, C++과 마찬가지로 swift 언어의 라이브러리를 이용해서 외부 서버랑 통신을 주고 받을 수 있다. 절차도 비슷한데

 

1. 먼저 통신할 주소를 URL 클래스로 생성하고

2. URLSession 을 만든 다음 

3. URLSession에 아까 만든 주소로 task를 할당하고 응답시 처리할 코드를 입력하고

4. task를 실행한다.

 

func performRequest(_ urlString: String) {
    // 1. Create a URL
    
    if let url = URL(string: urlString) {
        // 2. Create a URL session
        
        let session = URLSession(configuration: .default)
        
        // 3. Give the session a task
        
        let task = session.dataTask(with: url) { (data, response, error) in
            if  error != nil {
                self.delegate?.didFailWithError(error!)
                return
            }
            
            if let safeData = data {
                if let weather = self.parseJSON(safeData) {
                    self.delegate?.didUpdateWeather(self, weather)
                }
            }
        }
        
        // 4. Start the task
        task.resume()
    }
}
728x90

'개발 > iOS' 카테고리의 다른 글

UserDefaults  (0) 2020.12.05
IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30
tableview  (0) 2020.11.24
codable  (0) 2020.11.23

Pod

개발/iOS 2020. 11. 30. 10:32 Posted by 아는 개발자

안드로이드에서 build.gradle 파일을 수정해 파이어베이스나 외부 깃허브 라이브러리를 다운 받을 수 있었던 것처럼 Xcode에서는 cocoapod과 swiftpackage 라는 툴로 이런 기능을 제공하고 있는데 . swiftpackage는 비교적 최근에 애플에서 만들었고 사용하기도 간편하지만 보편화되지 않아서 아직까지는 cocoapod을 상요하는 추세라고 한다. 이번 포스트에서는 cocoapod을 사용해서 외부 라이브러리를 임포트 하는 방법을 다뤄보려고 한다.

 

1. cocoapod 설치하기 

 

맥북에 cocoapod이 설치되지 않았다면 아래 명령어로 터미널을 켜서 cocoapod을 먼저 설치한다.

sudo gem install cocoapods
pod setup --verbose

 

2. 프로젝트에 pod 초기화하기 

 

개발중인 Xcode 프로젝트의 최상단 위치에서 아래 명령어를 수행한다. 최상단 위치는 *.xcodeproj 이런 파일이 있는 곳을 말한다.

pod init // 프로젝트 최상위 위치에서

 

실행하고나면 Podfile 이라는 루비 언어로 작성된 파일이 생긴다. 

 

platform :ios, '13.0'

target 'Flash Chat iOS13' do
  use_frameworks!

  # Pods for Flash Chat iOS13
end

 

3. 추가하고 싶은 라이브러리 넣기 

 

프로젝트에서 사용하려는 Third party 라이브러리 스크립트를 Podfile에 추가한다. 스크립트는 사용하려는 프로젝트 라이브러리의 github에서 확인 할 수 있다.

 

platform :ios, '13.0'

target 'Flash Chat iOS13' do
  use_frameworks!

  # Pods for Flash Chat iOS13
  pod 'Firebase/Auth'
  pod 'Firebase/Core'
  pod 'Firebase/Firestore'
  pod 'IQKeyboardManagerSwift'

end

 

4. 설치 스크립트 실행

 

라이브러리를 추가했다면 아래 코드를 실행해서 파일을 받아온다. 

 

pod install

 

5. 새로운 프로젝트 실행 

 

4번의 작업이 끝나면 아래 그림처럼 xcworkdspace 라는 확장자를 가진 파일이 만들어진다. 이 파일을 실행해서 프로젝트를 다시 실행한다. 프로젝트에 Pod을 설정하는 작업이기 때문에 처음 Pod을 초기화 할 때만 해주면 된다. 

 

728x90

'개발 > iOS' 카테고리의 다른 글

IQKeyboardManager  (0) 2020.11.30
URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30
tableview  (0) 2020.11.24
codable  (0) 2020.11.23
extension  (0) 2020.11.23

tableview

개발/iOS 2020. 11. 24. 13:37 Posted by 아는 개발자

안드로이드의 listview, recyclerview 처럼 ios에서도 여러 개의 동일한 형태의 아이템을 리스트 형태로 보여주는 UI 라이브러리가 있는데 바로 tableview 다. ios의 tableview는 크게 여러 개의 아이템을 바인딩하는 tableview와 각 table 안에 item을 그리는 cell로 이뤄져 있는데 안드로이드의 recyclerview와 viewholder 간의 관계와 동일하게 보면 될 것 같다. 이번 포스트에서는 view controller에서 가지고 있는 데이터를 tableview를 이용해서 리스트의 형태로 보여주는 간단한 예제를 다뤄보려고한다. 

 

1. cell 생성 

 

테이블에서 보여줄 아이템의 UI를 스토리보드의 형태로 그리는 작업이다. New File -> Cocoa Touch Class -> Subclass Of UITableViewCell 선택, Also create XIB file 선택 으로 아이템의 UI를 직접 그릴 수 있다. 스토리보드에서 했던것 처럼 디자인 하고, Cell의 identifier 값을 입력해둔다.

 

cell도 storyboard처럼 assitant를 사용해서 편집 할 수 있다.

 

2. tableview 초기화 

 

선작업으로 스토리보드에 추가한 TableView를 ViewController에 바인딩하고 리스트에 보여줄 아이템을 messages 변수 내에 담았다.

class ChatViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var messageTextfield: UITextField!
    
    var messages: [Message] = [
        Message(sender: "1@2.com", body: "Hey"),
        Message(sender: "a@b.com", body: "Hello!"),
        Message(sender: "1@2.com", body: "What's up")
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = K.appName
        tableView.delegate = self
        navigationItem.hidesBackButton = true
        
        tableView.register(UINib(nibName: "MessageCell", bundle: nil), forCellReuseIdentifier: "ReusableCell")
    }
}

extension ChatViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // click listener
        print(indexPath.row)
    }
}

ViewController 내에서 tableview의 delegate과 datasource를 초기화해준다. tableView의 변수 delegate는 tableview 내의 item을 클릭 할 때 이벤트를 받기 위한 콜백이고 dataSource는 ViewController에서 갖고 있는 아이템을 바인딩하기 위함이다. 

 

그 아래 tableView.register() 함수를 호출하는데 이것은 tableview에서 사용할 cell을 등록하는 작업이다. nibName으로 커스텀 디자인한 cell을 선택할 수 있는데 앞서 디자인한 파일인 "MessageCell"을 선택하고 forCellReuseIdentifier로는 앞서 정의한 "ReusableCell"을 둔다. 

 

3. data 바인딩

 

 extension ChatViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return messages.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ReusableCell", for: indexPath) as! MessageCell
        
        cell.label.text = messages[indexPath.row].body
        return cell 
    }
}

 

tableView.dataSource = self 을 구현하는 부분이며 실제로 ViewController에서 갖고 있는 메시지에 넣는 작업이다. 첫번째 함수에서는 item으로 표시할 메시지의 개수를 리턴하고 두번째 함수에서는 tableview에서 만든 cell에 데이터를 등록한다. 앞서 cell의 identifier을 ReusableCell으로 지정했으므로 withIdentifier로 동일한 값을 넣어서 받을 수 있는 메시지 셀을 받고 MessageCell로 타입캐스팅해서 클래스에 값을 입력하는 용도로 쓸 수 있다.

 

4. 구현 결과 

 

728x90

'개발 > iOS' 카테고리의 다른 글

URLSession  (0) 2020.11.30
Pod  (0) 2020.11.30
tableview  (0) 2020.11.24
codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23

codable

개발/iOS 2020. 11. 23. 15:49 Posted by 아는 개발자

codable은 swift4에서 추가된 프로토콜로 JSON 처리를 손쉽게 해준다. 예로 서버로부터 이런 json 결과물을 받으면 

 

{
  "coord": {
    "lon": -0.13,
    "lat": 51.51
  },
  "weather": [
    {
      "id": 721,
      "main": "Haze",
      "description": "haze",
      "icon": "50n"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 4.91,
    "feels_like": 1.93,
    "temp_min": 4.44,
    "temp_max": 6,
    "pressure": 1028,
    "humidity": 87
  },
  "visibility": 2800,
  "wind": {
    "speed": 2.1,
    "deg": 230
  },
  "clouds": {
    "all": 71
  },
  "dt": 1606106846,
  "sys": {
    "type": 1,
    "id": 1414,
    "country": "GB",
    "sunrise": 1606116755,
    "sunset": 1606147310
  },
  "timezone": 0,
  "id": 2643743,
  "name": "London",
  "cod": 200
}

 

Codable과 Struct을 조합해서 필요한 값들을 추출해줄 수 있다. JSON 값의 key와 데이터 타입만 일치하면 받아오는데는 문제 없다.

 

struct WeatherData: Codable {
    let name: String
    let main: Main
    let weather: [Weather]
}

struct Main: Codable {
    let temp: Double
}

struct Weather: Codable {
    let id: Int
    let description: String
}

////
func parseJSON(_ weatherData: Data) -> WeatherModel? {
    let decoder = JSONDecoder()
    
    do {
        let decodedData = try decoder.decode(WeatherData.self, from: weatherData)
        
        let id = decodedData.weather[0].id
        let temp = decodedData.main.temp
        let name = decodedData.name
        let weather = WeatherModel(conditionId: id, cityName: name, temperature: temp)
        return weather
    } catch {
        delegate?.didFailWithError(error)
        return nil
    }
}
728x90

'개발 > iOS' 카테고리의 다른 글

Pod  (0) 2020.11.30
tableview  (0) 2020.11.24
codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
TAG Codable, ios, Swift

extension

개발/iOS 2020. 11. 23. 15:39 Posted by 아는 개발자

swift의 extension 은 클래스나 프로토콜에 새로운 함수를 추가해주는 기능을 제공한다. 일반적인 경우 클래스에 새로운 함수를 추가할 때 클래스를 수정하면 되지만 import 해서 사용중인 클래스의 경우에는 수정 할 수가 없다. 클래스는 수정 할 수 없으나 범용적으로 사용하는 함수의 경우에는 extension을 사용하면된다. 

 

예를들어 아래 코드처럼 둥근 빨간 버튼을 만드는 경우 이렇게 구현을 하면 가능하긴 하나 매번 이런식으로 만들어야되면 코드의 가독성이 떨어진다. 

 

이런 경우 extension을 이용해 UIButton 클래스에 새로운 함수, makeCircular를 만들 수 있다. 이러면 앞서 둥근 버튼을 만들기 위해 실행한 코드를 하나의 함수에 넣고 호출하는 식으로 해결 할 수 있다. UIButton 클래스를 수정 할 수는 없지만 새로운 함수를 추가함으로써 가능한 방식이다.

 

 

extension은 protocol에 적용할 때 유용한데 이때는 함수의 기본 body를 만들어줘서 상속받을 때 기본 body가 있는 함수들은 따로 구현하지 않아도 된다는 장점이 있다.  아래 코드를 보면 WeatherManagerDelegate라는 프로토콜은 두개의 함수가 있는데 그 아래 extension에서 didFailWithError 함수에 대해서 body를 넣어뒀다. 

 

protocol WeatherManagerDelegate {
    func didUpdateWeather(_ weatherManager: WeatherManager, _ weather: WeatherModel)
    
    func didFailWithError(_ error: Error)
}

extension WeatherManagerDelegate {
    func didFailWithError(_ error: Error) { }
}

 

이렇게 기본 body를 세팅해주면 WeatherManagerDelegate를 받는 쪽에서는 didUpdateWeather 함수만 구현하면 되고, didFailWithError는 구현하지 않아도 된다. 반드시 필요한 함수만 구현토록해 가독성을 좋아지게 하는 방법이다.

728x90

'개발 > iOS' 카테고리의 다른 글

tableview  (0) 2020.11.24
codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
protocol  (0) 2020.11.21

closure

개발/iOS 2020. 11. 23. 13:37 Posted by 아는 개발자

프로그래밍 함수에서는 int, float 같은 데이터 타입 인자 뿐만 아니라 함수를 전달할 수도 있는데 swift에서도 동일하게 가능하다. 함수의 인자로는 이미 있는 함수를 넣을 수 있고 뿐만 아니라 클로저(closure)의 형태로 익명의 함수(anonymous)를 만들어서 쓰는 것도 가능하다. 예로 아래에 있는 코드들은 모두 동일한 기능을 하는데 함수를 전달하는 것과 다른 클로저 형태를 사용한 것만 다르다. 자바나 코틀린을 써본 사람들은 람다 함수라는 이름으로 이미 익숙할 것이다.

 

func calculator (n1: Int, n2: Int, operation: (Int, Int) -> Int) -> Int {
    return operation(n1, n2)
}

func multiply(no1: Int, no2: Int) -> Int {
    return no1 * no2
}

let res1 = calculator(n1: 2, n2: 3, operation: multiply)
let res2 = calculator(n1: 2, n2: 3, operation: { (no1: Int, no2: Int) -> Int in
    return no1 * no2
})
let res3 = calculator(n1: 2, n2: 3, operation: { (no1, no2) in no1 * no2 })
let res4 = calculator(n1: 2, n2: 3, operation: { $0 * $1 })

print("res1: \(res1) res2: \(res2) res3: \(res3) res4: \(res4)")

각 함수의 결과값은 6으로 모두 동일하다

클로저를 쓰면 함수를 짧고 유연하게 만들 수 있다는 장점이 있다. 하지만 $0, $1 까지 사용하면 가독성(readability)이 떨어질 수도 있기 때문에 적재적소에 사용하는 것이 좋겠다.

728x90

'개발 > iOS' 카테고리의 다른 글

codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
protocol  (0) 2020.11.21
Delegate  (0) 2020.11.21

segue

개발/iOS 2020. 11. 23. 12:24 Posted by 아는 개발자

segue(세그웨이)는 swift에선 ViewController 간의 전환하는 용도로 사용하는 라이브러리인데 ViewController가 하나의 화면을 담당하므로 화면 전환을 위한 라이브러리라고 봐도 될 것 같다. segue 가 포르투갈어로 팔로우라는 뜻을 가지고 있으니 단어의 의미와 함께 용도를 기억하면 좋을 것 같다.

 

세그웨이는 뷰 컨트롤러간의 연결을 통해서 만들수 있다. 스토리보드에서 작업중인 두개의 뷰컨트롤러중 화면전환이 시작되는 곳에서 도착지점까지 컨트롤을 눌러서 쭉 끌어준다. 실제 코드에 바인딩 해줄때 처럼 말이다. 

 

xcode는 왜이렇게 드래그를 좋아하는 걸까

 

옮기고 나면 위 그림의 파란색 박스로 표시한 새로운 아이템이 생긴다. 클릭하면 오른쪽 상단에서 세부 속성을 정의해 줄 수 있는데 identifier와 종류를 선택할 수 있다. identifier는 화면 전환 이벤트의 id와 같은 개념이고 kind는 새로운 view controller를 어떻게 띄워줄지를 선택 할 수 있다. 선호하는 방식대로 띄워주면 된다.

 

 

스토리보드에서 화면 전환에 대해서 정의한 후 ViewController에선 앞서 정의한 액션을 호출하는 역할을 한다. 아래 코드를 보면 calculatePressed 라고 스토리보드에 바인딩된 함수에서 performSegue 함수를 호출한다. 이때 withIdentifier에 들어가는 인자를 보면 아까 스토리보드에서 정의한 identifier와 동일한 값이 들어가있는걸 볼 수 있다. identifier 값을 확인해서 어떤 세그웨이를 사용할 지 선택하는 것이다. 

 

    @IBAction func calculatePressed(_ sender: UIButton) {
        print(heightSlider.value)
        print(weightSlider.value)
        
        let height = heightSlider.value
        let weight = weightSlider.value
        
        calculatorBrain.updateBmi(height, weight)

        self.performSegue(withIdentifier: "goToResult", sender: self)
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "goToResult" {
            let destinationVC = segue.destination as! ResultViewController
            
            destinationVC.bmiValue = calculatorBrain.bmi
        }
    }

 

performSegue 함수를 호출하면 아래 오버라이딩된 prepare 함수가 호출되는데 이때 어떤 세그웨이가 호출됐는지 인자를 확인 할 수 있고 새로 생성된 ViewController 함수를 볼 수 있다. 위 코드를 보면 세그웨이의 도착점을 ResultViewController 클래스로 타입 캐스팅 한 것을 볼 수 있다. 그리고 새로운 ViewController에 전달하고 싶은 bmiValue를 입력하고 있는 것도 볼 수 있다.

728x90

'개발 > iOS' 카테고리의 다른 글

codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
protocol  (0) 2020.11.21
Delegate  (0) 2020.11.21

protocol

개발/iOS 2020. 11. 21. 16:19 Posted by 아는 개발자

protocol은 네트워크나 의전에서 주로 쓰이는 용어인데 swift에서는 네트워크와 의전에서 사용되는 의미랑은 조금 다른 차원인 것 같고 차라리 자바나 코틀린에서 사용하는 interface가 좀더 와닿는 느낌이며 실제로도 비슷한 역할을 한다(자바 개발자를 너무 오래해서 그런가). 이 포스트에서는 객체 지향적 관점에서 protocol을 왜 사용해야하는지를 설명하기 보다는 어떻게 쓰는지를 중점적으로 설명하고자 한다.

 

protocol은 delegate 패턴을 쓸 때 사용하는 타입이다. delegate 패턴을 사용하는 예제 코드를 통해 protocol을 살펴보자.

 

protocol AdvancedLifeSupport {
    func performCPR()
}

class EmergencyCallHandler {
    var delegate: AdvancedLifeSupport?
    
    func assessSituation() {
        print("Can you tell me what happened?")
    }
    
    func medicalEmergency() {
        delegate?.performCPR()
    }
}

 

EmegencyCallHandler는 위급 상황을 평가하는 함수(assessSituation) 와 처리하는 함수(medicalEmergency)를 갖고 있는데 처리하는 작업은 다른 클래스에서 작업을 위임하고 있다. 위임할 때 호출하는 변수인 delegate는 AdvancedLifeSupport 라는 프로토콜 타입의 클래스로 선언돼있다. 

 

AdvanceLifeSupport protocol을 보면 performCPR() 함수만 있고 내부의 body는 구현되지 않았다. 이 함수는 이 명령을 위임 받을 클래스에서 반드시 구현해야 하는 내용이다.

 

class Doctor: AdvancedLifeSupport {
    func performCPR() {
        print("The doctor does chest compressions, 30 per second.")
    }
    
    init (handler: EmergencyCallHandler) {
        handler.delegate = self
    }
}

let handler = EmergencyCallHandler()
let doctor = Doctor(handler: handler)

handler.assessSituation()
handler.medicalEmergency()

 

Doctor 클래스에서는 AdvancedLifeSupport protocol을 구현하고 있다. body만 빠져 있던 performCPR 함수에 Doctor 클래스에서 처리하고 있다는 내용이 담긴 로그를 남기고 있다. 이 함수를 구현하지 않으면 컴파일 에러가 뜬다. 이 함수를 위임 받았으니, 해당하는 작업을 처리하라는 뜻이다.

 

아래 클래스에서 EmergencyHandler와 Doctor 클래스를 각각 초기화하고 Handler 에서 함수를 호출하면 다음과 같은 콘솔이 뜨는 것을 볼 수 있다. EmergencyHandler에서 작업을 위임 받은 Doctor 클래스에서 선언한 performCPR 함수가 호출되고 있다.

 

 

728x90

'개발 > iOS' 카테고리의 다른 글

codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
protocol  (0) 2020.11.21
Delegate  (0) 2020.11.21
TAG ios, Protocol

Delegate

개발/iOS 2020. 11. 21. 16:02 Posted by 아는 개발자

swift 언어에서 사용하는 delegate 패턴은 용어에도 담긴 의미 처럼 특정한 작업을 다른 클래스에 위임 할 때 사용하는 디자인 패턴이다. 프로그래밍을 처음 경험하는 사람들은 어렵게 느낄 수 있으나 다른 언어를 먼저 경험해본 사람들한테는 콜백과 비슷한 사용 용도라고 될 것 같다. UI 클래스에서 주로 사용하는데 UITextField 클래스에서 사용 예시를 한번 확인해보자.

 

 

위 그림의 상단에 사용자로부터 텍스트를 받을 수 있도록 UITextField를 만들어뒀다. 시뮬레이터에서 유저가 이 영역을 클릭하면 자동으로 키보드가 올라오게 되는데 UITextField만 추가하고 아무런 추가 작업이 없었다면 엔터에 해당하는 이동 버튼을 눌러도 아무런 응답이 없다. 엔터 명령에 대해서 어떤 클래스도 이 작업을 위임 받지 않았기 때문이다.

 

iOS 상에선 특정 TextField 내에서 엔터 명령은 UITextField 내부적으로 처리하고 있다. 유저가 엔터를 여러번 클릭할 수록 UITextField 내부에서는 엔터 명령을 받게 된다. 개발자가 할 일은 UITextField의 엔터 작업을 위임 받을 클래스를 선언하고 그에 해당하는 명령을 처리하는 것이다. 다양한 방법으로 이 작업을 할 수 있는데 일반적으로 UITextField가 있는 ViewController에서 이 작업을 위임해준다

 

class WeatherViewController: UIViewController, UITextFieldDelegate {

    @IBOutlet weak var conditionImageView: UIImageView!
    @IBOutlet weak var temperatureLabel: UILabel!
    @IBOutlet weak var cityLabel: UILabel!
    @IBOutlet weak var searchTextField: UITextField!
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        searchTextField.endEditing(true)
        print(searchTextField.text!)
        
        return true
    }    

 

위의 코드를 보면 ViewController 클래스에서 UITextFieldDelegate 프로토콜을 받고 있고 이 프로토콜에 있는 textFieldShouldReturn 함수를 구현해둔 것을 볼 수 있다. 이 함수는 해당 UITextField의 키보드에서 엔터 명령이 있는 경우에 불린다. 위임 받은 클래스에선 편집 작업을 종료하고 현재 입력된 값을 출력하는 작업을 넣었다.

 

public protocol UITextFieldDelegate : NSObjectProtocol {

    
    @available(iOS 2.0, *)
    optional func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool

    @available(iOS 2.0, *)
    optional func textFieldDidBeginEditing(_ textField: UITextField)

    @available(iOS 2.0, *)
    optional func textFieldShouldEndEditing(_ textField: UITextField) -> Bool

    @available(iOS 2.0, *)
    optional func textFieldDidEndEditing(_ textField: UITextField)

 

UITextField에선 다른 작업에 대해서도 위임 받을 수 있는데 입력을 시작할 때 호출되는 textFieldDidBeginEditing 과 입력이 끝날 때 호출되는 textFieldDidEndEditing 같은 것들이 있다. 실제 코드로 따라가보면 더 많은 함수들이 있으니 구현할 때 참고해보면 좋을 것 같다.

 

이렇게 작업을 위임하는 delegate 패턴은 protocol 이라는 것을 사용해서 구현한다. 자바의 interface와 엇비슷하고 코틀린의 interface와는 아주 비슷한데 이에 대한 내용은 다음 포스트에 다루려고 한다.

728x90

'개발 > iOS' 카테고리의 다른 글

codable  (0) 2020.11.23
extension  (0) 2020.11.23
closure  (0) 2020.11.23
segue  (0) 2020.11.23
protocol  (0) 2020.11.21
Delegate  (0) 2020.11.21

이전 포스트에서는 카메라에서 담고 있는 프레임을 OpenGL로 그린 후 GLSurfaceView로 그려주는 작업을 했었다. 지금부터는 그려진 이미지를 비디오 파일로 만드는 작업에 대해서 분석해보고자 한다.

 

3. 미리보기 영상 인코딩하기 

 

MediaCodec을 사용한 비디오 인코딩 작업도 Renderer와 동일하게 OpenGL을 이용한 그리기 작업이 필요하다. 전반적인 구현 아이디어는 비디오 녹화용 EGL Context를 선언한 후 Renderer 클래스로부터 현재 촬영 중인 카메라의 이미지를 받아와 OpenGL로 다시 그려주고 Media Codec에서 받을 수 있는 Surface 형태로 보내는 것이다.

 

3.1 비디오 인코딩용 EGL Context 선언 

 

비디오 녹화 작업도 OpenGL 작업이 필요하므로 OpenGL 작업용 EGLContext를 만들어준다. 이때 Renderer로부터 카메라 촬영 이미지를 받아오기 위해 EGL 초기화 작업에 공유 EGL Context 정보(shared_context)를 추가한다. 

fun setVideoEncoder(videoEncoder: MediaVideoEncoder?) {
    this.videoEncoder = videoEncoder

    videoEncoder?.setEglContext(EGL14.eglGetCurrentContext(), textureId)
}

private fun createContext(shared_context: EGLContext?): EGLContext {
    val attrib_list = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
    val context =
        EGL14.eglCreateContext(mEglDisplay, mEglConfig, shared_context, attrib_list, 0)
    checkEglError("eglCreateContext")
    return context
}

 

3.2 Renderer로부터 카메라 이미지 받아오기 

 

Renderer로부터 새로운 프레임이 발생했다는 콜백을 받으면 VideoEncoder는 카메라로부터 이미지를 받아와서 새롭게 그려주게 된다. 카메라 이미지는 Renderer 클래스 내의 texture에 있으며 고유한 texture id를 EGL내에서 bind 해서 받아 올 수 있게 된다. VideoEncoder 클래스에 해당 textureId를 전달해서 VideoEncoder의 EGLDisplay에 그려준다.

fun draw(tex_id: Int, tex_matrix: FloatArray?) {
    GLES20.glUseProgram(hProgram)
    if (tex_matrix != null) GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, tex_matrix, 0)
    GLES20.glUniformMatrix4fv(muMVPMatrixLoc, 1, false, mMvpMatrix, 0)
    GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, tex_id)
    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, VERTEX_NUM)
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0)
    GLES20.glUseProgram(0)
}

 

3.3 MediaCodec으로 이미지 버퍼 전달 

 

인코딩을 위해 새롭게 그린 이미지를 MediaCodec에서 만든 Surface에 버퍼로 전달한다. OpenGL 에서 제공하는 eglSwapBuffers 함수를 사용하면 MediaCodec이 받을 수 있는 surface에 전달이 가능하다.

private fun swap(surface: EGLSurface?): Int {
    if (!EGL14.eglSwapBuffers(mEglDisplay, surface)) {
        val err = EGL14.eglGetError()
        if (DEBUG) Log.w(TAG, "swap:err=$err")
        return err
    }
    return EGL14.EGL_SUCCESS
}

 

3.4 전달 받은 정보를 인코딩 

 

MediaCodec 고유 함수를 이용해 전달받은 정보를 인코딩한다. MediaCodec 관련 코드는 생략한다. 

 

4. 마치며 

 

포스트에선 전반적인 구현 아이디어만 다루었기 때문에 보기에는 쉽지만 실제 사용된 코드는 꽤 복잡했다. google에서 짜둔 클래스 간의 상속과 인터페이스 관계를 따라가는 게 생각보다 시간이 걸렸고 아직도 생소한 OpenGL 클래스의 역할과 내부 코드를 알지 못해 문서를 찾아가느라 어려웠다.

 

그래도 고생하면서 생소했던 카메라와 OpenGL 관련 지식을 배운게 개발자로서 큰 소득이다. 스노나 틱톡의 카메라 효과 코드를 보진 못했지만 아마 위 구현 방식과 크게 차이가 나지 않을 것 같다. 여기에 OpenGL 코드를 더 확장시키면 나도 촬영 중인 화면에 여기에 필터를 변경하고 스티커도 추가해볼 수 있겠다. 현재 구현된 코드를 한층 더 업그레이드시켜봐야겠다.

 

오디오까지 같이 녹화하고 싶다면 여기 깃허브 코드를 참조하면 좋다.

 

https://github.com/saki4510t/AudioVideoRecordingSample

728x90

0. MediaRecorder의 한계

 

구글이 운영 중인 안드로이드 카메라 Sample 코드 저장소에선 Camera2Camera 2 라이브러리를 이용해 사진을 찍거나 비디오 녹화를 할 수 있는 예제가 있다. Camera 2 Video 프로젝트의 비디오 녹화 예제 코드의 경우 카메라에서 출력되는 프레임을 MediaRecorder라는 클래스를 이용해서 녹화할 수 있도록 했는데 이 방식은 후면 녹화의 경우에는 별로 문제가 없으나 전면 카메라를 이용하는 경우 미리보기에서 나온 영상이 그대로 저장되지 않고 좌우가 반전돼서 나오게 되는 문제가 있다. 대부분 카메라 어플에서 제공하는 옵션인 보이는 대로 저장 하기 기능을 사용할 수 없는 큰 문제점(?) 이 존재한다

 

보이는대로 저장 옵션을 사용할 수 없다. 그래서 촬영한후 저장한 내 모습이 아주 어색하게 저장된다

 

전면 카메라에 출력된 내 모습 그대로 저장하기 위해선 MediaRecorder 클래스 대신 대신 카메라에서 출력된 프레임을 OpenGL 그래픽 라이브러리를 이용해 렌더링 한 후 화면에 출력된 프레임을 MediaCodec을 이용해 직접 비디오 파일을 만드는 과정이 필요하다. MediaRecorder를 사용하는 코드가 워낙 간편했거니와 그래픽 라이브러리와 MediaCodec을 사용하는 작업은 대부분 개발자들에게도 생소한 OpenGL 지식이 필요하기 때문에 다소 까다롭다. 하지만 이것 말고는 전면 카메라를 반전시킬 방법은 없기 때문에 어렵더라도 직접 구현해봤다.

 

1. 오픈소스

 

다행히 구글의 비공식 저장소인 grafika에서 이미 구현한 코드가 있었다. 카메라에서 촬영중인 프레임을 안드로이드 그래픽 라이브러리에 렌더링 한 후 화면에 출력된 이미지를 MediaCodec을 이용해 MP4의 파일로 만드는, 앞서 의도한 방식을 그대로 구현한 코드였다. 그런데 3-4년 전에 작성한 코드라서 현재는 Deprecated 된 Camera 라이브러리를 사용 중이어서(현재는 Camera 2를 주로 쓰고 CameraX 알파 버전이 개발 중이다) grafika 코드를 분석하고 여기서 동작하는 모듈을 Camera 2랑도 연동이 될 수 있도록 하는 방향으로 개발했다.

 

2. Camera2와의 GLSurfaceView 연동 과정 

 

camera2 구조

grafika에서 이미 구현한 부분은 Surface, Renderer, GLSurface 간의 연동 과정이고 내가 추가적으로 넣은 부분은 Camera 2와 Renderer에서 만든 Surface를 연동한 부분뿐이다. 연동 과정과 각 클래스의 역할을 분석한 내용을 단계별로 정리했다.

 

2.1 Renderer 초기화 작업

 

GLSurfaceView는 OpenGL로 그려진 이미지를 안드로이드 UI에 노출 시켜줄 수 있는 클래스다. Renderer 클래스는 GLSurfaceView에 표시할 이미지를 OpenGL로 그리는 역할을 한다. Renderer 클래스가 GLSurfaceView의 그리는 역할을 담당할 수 있도록 setRenderer  함수를 이용해 두 클래스를 연결시켜준다. 이러면 Renderer에서 그린 OpenGL 이미지가 GLSurfaceView에 표시된다.

 

class CameraSurfaceRenderer(private val glSurfaceView: GLSurfaceView) : GLSurfaceView.Renderer,

    init {
        Matrix.setIdentityM(mvpMatrix, 0)
        glSurfaceView.setEGLContextClientVersion(2)
        glSurfaceView.setRenderer(this)
        glSurfaceView.renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
    }

 

연결 작업후 OpenGL을 이용해 그릴 수 있는 공간을 선언하는 초기 작업이 필요한데 이 작업은 Surface가 생성된 이후에 불리는 onSurfaceCreated 콜백 함수에서 담당한다. 이 함수 내에서는 OpenGL Texture를 생성하는 초기화 외부로부터 이미지 스트림을 받을 SurfaceTexture를 선언한다. SurfaceTexture는 OpenGL Texture로 이미지 스트림을 보내는 역할을 한다.

 

class CameraSurfaceRenderer(private val glSurfaceView: GLSurfaceView): GLSurfaceView.Renderer, SurfaceTexture.OnFrameAvailableListener {
    @SuppressLint("Recycle")
    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        hTex = GLDrawer2D.initTex()
        surfaceTexture = SurfaceTexture(hTex)
        surfaceTexture?.setOnFrameAvailableListener(this)

        // clear screen with yellow color so that you can see rendering rectangle
        GLES20.glClearColor(1.0f, 1.0f, 0.0f, 1.0f)

        drawer = GLDrawer2D()
        drawer.setMatrix(mvpMatrix, 0)
    }
}

 

2.2 Camera2 촬영 중인 공간 표시

 

Camera 2에서는 Surface 클래스의 형태로 카메라에서 보고 있는 이미지를 전달받을 수 있다. 앞서 Renderer에서 외부로부터 이미지를 받을 공간을 SurfaceTexture로 선언했는데 카메라의 이 클래스를 이용해 Surface를 만들면 카메라로부터 이미지를 전달받고 미리보기로 보여줄 수 있게 된다.

private fun startRecordingVideo() {
    if (cameraDevice == null) return

    try {
        closePreviewSession()

        // Set up Surface for camera preview
        val previewSurface = Surface(renderer.surfaceTexture)
        val surfaces = ArrayList<Surface>().apply {
            add(previewSurface)
        }
        previewRequestBuilder = cameraDevice!!.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
            addTarget(previewSurface)
        }

 

2.3. 카메라로 부터 받은 이미지 스트림을 OpenGL로 그리기

 

카메라에서 Renderer에서 선언한 SurfaceTexture에 이미지 스트림으로 보내주는 것이 됐으니 카메라가 보고 있는 이미지 스트림 정보를 실제로 OpenGL 코드로 그려주는 작업이 필요하다. Renderer 함수에는 두 개의 콜백 함수가 있는데 카메라에서 이미지를 전달받은 SurfaceTexture가 호출하는 onFrameAvailable() 콜백이 먼저 불린다. 이 함수에선 최신 이미지가 도착했으니 현재 Renderer와 연동된 GLSurfaceView에게 업데이트를 요청하는 함수인 requestRender() 함수를 호출한다.

 

requestRender() 호출 후엔 연달아서 onDrawFrame() 이 불리는데 여기선 SurfaceTexture로 전달받은 카메라의 프레임 정보를 OpenGL Texture에 업데이트 시킨 후 OpenGL 명령어로 화면에 그리는 작업을 한다. 이 작업이 생략되면 카메라 초기화 작업은 잘 됐음에도 불구하고 화면에는 검은 화면만 뜨게 된다. OpenGL로 그림을 그리는 코드는 생략했다.

 

override fun onFrameAvailable(surfaceTexture: SurfaceTexture?) {
    requesrUpdateTex = true
    glSurfaceView.requestRender()
}

override fun onDrawFrame(gl: GL10?) {
    if (requesrUpdateTex) {
        requesrUpdateTex = false
        surfaceTexture?.updateTexImage() // 전달 받은 카메라 이미지 프레임을 OpenGL Texture 에 업데이트
        surfaceTexture?.getTransformMatrix(stMatrix)
    }

    drawer.draw(hTex, stMatrix) // Texture 가지고 OpenGL로 그림을 그린다
    if (recording) {
        videoEncoder?.frameAvailableSoon(stMatrix, mvpMatrix)
    }
}

 

여기까지 완료하면 Camera2를 이용해서 출력 중인 화면을 OpenGL 코드로 화면에 보여주는 것까지 가능하다. 그러나 녹화를 하기 위해선 Renderer에서 출력되고 있는 프레임 정보를 MediaCodec으로 보내서 MP4 파일을 만드는 작업까지 가야 하는데 이 내용은 다음 포스트에서 다룰 예정이다.

728x90

RecyclerView 올바른 사용 방법

개발/안드로이드 2020. 7. 22. 19:38 Posted by 아는 개발자

 

사진첩 내용이랑 리뷰 처럼 동일한 형태의 아이템을 리스트로 띄우고 있다

전화번호부, 구글 편지함, 사진첩 등등 요즘 출시되는 대부분의 앱에선 전화번호 정보나 사진과 같은 동일한 유형의 아이템을 리스트로 보여주는 뷰를 가지고 있다. 하나의 아이템은 간단하게 텍스트 정보만 가지는 것부터 사진, 동영상처럼 파일 크기를 많이 차지하는 미디어까지 서비스마다 다양한 방식으로 효과를 준다. 이 아이템 효과를 개발적인 측면에서 고려해보면 화면에 보여주려는 아이템이 차지하는 메모리 크기가 얼마 되지 않는다면 리스트에 붙인 모든 아이템을 바로 화면에 렌딩해도 크게 문제가 되진 않지만 파일 크기가 큰 미디어를 포함하는 아이템을 한번에 렌딩한다면 프로그램에서 순식간에 대량의 메모리를 차지하게되는 문제가 생긴다. 스크롤이 버벅거리다가 Out of Memory 가 발생해서 앱이 죽는 경우가 이런 경우다.

 

이런 경우를 위해 만들어진 라이브러리가 RecyclerView 클래스다. RecyclerView 클래스는 대량의 아이템을 리스트로 보여줄때 실제로 화면에 비춰지는 아이템만 렌딩하도록 만들어 앱에서 사용하는 메모리의 양을 최소화 했다. 실제로 구현해보면 100개의 아이템을 RecyclerView에 붙여도 화면에 바인딩 되는 아이템은 화면에 비춰지는 것과 캐싱용으로 그 근처에 있는 아이템인 7~8개 남짓이다. 나머지 아이템은 스크롤해서 가까이 이동할 때 읽어와 화면에 띄워줘서 메모리 공간을 절약하는 방식이다. RecyclerView가 대신 메모리 관리를 해주기 때문에 개발자는 화면에 뿌려줄 아이템만 찾아주는데 집중하면 된다.

 

RecyclerView를 잘못 사용하는 경우는 RecyclerView의 장점인 메모리 관리 기능을 살리지 않고 사용할 때다. 화면에 비춰지는 것들만 화면에 렌딩해야하는데 잘못 사용하다보면 화면에 비춰지지 않는 아이템들도 바인딩 시켜버려서 메모리를 급격하게 잡아 먹게돼 폰이 갑자기 느려지는 경우가 생긴다. 물론 이런 유형의 버그를 만드는 것도 쉽진 않다. 하지만 화면에 다양한 유형의 아이템을 넣으려고 하다 보면 아래의 같은 구조로 코드를 짜게 되는데 이런 구조가 대표적으로 recyclerview를 잘못 쓸 수도 있게 되는 예다 (무조건 잘못하는 건 아니고)

 

 

위 그림에서 Parent RecyclerView는 Child RecyclerView 하나씩 나눠서 하게 된다. 그런데 Parent RecyclerView의 입장에서는 Child Recycler View가 가지고 있는 아이템이 얼마나 되는지는 모른다. 단지 첫번째 Child RecyclerView가 화면에 비치기만 한다면 각각이 갖고 있는 아이템의 개수와 상관 없이 화면에 바로 렌딩을 하게 된다. 여기서 만약 Child RecyclerView가 렌딩해야하는 총 크기가 화면 뷰를 벗어나지 않는다면 크게 문제가 되진 않는데 이를 벗어나 갖고 있는 모든 아이템을 렌딩하면 문제가 될 수도 있게 된다

 

 

윗 사진의 화면 페이지와 로그를 비교해보면 문제가 되는 것을 확인 할 수 있다. 실제로 화면에서는 0 ~ 7번까지만 화면에 보이고 있는데 로그에서는 불과 200ms 만에 21번까지 화면에 바인딩이 되버린다. 화면에서는 스트링 정보만 있기 때문에 스크롤 할 때 큰 문제가 되진 않는데 만약 리스트에 사진파일이나 동영상이 있으면 심각하게 스크롤이 잘 되지 않는 문제가 생긴다 (심지어 고성능 폰에서도 말이다) 

 

렉걸리는 문제에 대한 스택 오버플로우 답변으로는 각 아이템이 Constraint Layout을 없애서 아이템 하나가 차지하는 메모리의 크기를 줄이라고 하는데, 요새 폰들은 뷰의 부하 정도는 거뜬히 이겨낼 수 있을 정도로 좋아서 이부분이 그렇게까지 문제가 되는 것 같지는 않고 마지막에 최적화 할 부분이 없을 때 사용할 만한 팁인 정도다. 예상외로 심각하게 렉이 발생한다면 위 사례처럼 화면에 보이지 않는 아이템까지 바인딩하면서 메모리를 잡아먹고 있는것은 아닌지 확인하는게 좋을 것 같다.

 

728x90

 

Hilt를 소개하는 글을 쓰면서 알게된 Hilt의 여러가지 장점을 직접 몸소 체험해보고자 최근에 진행중인 프로젝트에서 쓰고 있는 Dagger를 Hilt로 마이그레이션을 해보려고 시도 했는데 중도에 포기했다. 구글 문서에서 마이그레이션 절차를 하나하나 자세히 설명해주고 있어 정보가 부족한 문제는 없었다. 그런데 문서를 차근차근 읽으면 읽을 수록 현재 서비스의 릴리즈 속도와 문서에서 해야하는 일들을 고려해봤을 때 이건 현재로선 도저히 손을 댈 수 없는 작업인 것 같았다. 이번 포스트에서는 내가 왜 마이그레이션을 할 수 없었는 지를 짤막하게 다뤄보려고 한다.

 

1. All or Nothing

 

Dagger와 Hilt는 하나의 앱에서 공존할 수 없다

처음에는 Hilt로 마이그레이션 하는 작업이 라이브러리 단위로 될 줄 알았다. 그래서 가장 위험부담이 적은 라이브러리부터 적용하는 방향을 고려 했었다. 그런데 Hilt를 적용하기 위해선 우선 Application에 해당하는 클래스에 Hilt 어노테이션을 추가해줘야했고, 이거를 붙이면 Dagger를 적용할 수 없게 된다. 그러니까 이 말의 뜻은 Hilt를 쓰려면 라이브러리 단위로 바꿔줄 수는 없고 모든 라이브러리에서 사용중인 Dagger코드를 Hilt로 바꿔줘야한다는 것이었다. 이게... 불가능 한 건 아닌데 Dagger의 단점으로 발생한 수 많은 boiler plate 코드를 어느 세월에 모두 바꿔치기 할 것인가...? 현재 진행 중인 프로젝트가 릴리즈 한지 얼마 안됐긴 했지만 그럼에도 불구하고 그 수많은 코드를 치우는게 압박감으로 다가왔었는데 큰 규모의 프로젝트는 더더욱 힘들 것 같다.

 

2. 역사가 짧은 라이브러리

 

"모든 Dagger 코드를 Hilt로 전환 하더라도 한 번 해보자!" 하는 마음이었으나 Hilt를 구글에 검색한 결과를 보고 바로 포기했다. 버전 명은 2.28 이라 많은 개선이 이뤄진 것 같은 걸로 보여지는데 안드로이드 Developer 미디엄에서는 이제야 "Hilt 써보세요" 하고 소개하고 있고 구글에서 검색한 결과도 Hilt를 간략하게 소개하는 글들만 있을 뿐 실제로 어떻게 사용해야 하는지에 대해서는 빈약해보인다. 다들 간만 보고 있는 건지 아니면 적용하고 있는건지 모르겠다. 스택오버플로우 질문도 얼마 없는 이 시점에서 함부로 들어 갔다간 구글도 모르는 버그를 경험하다가 프로젝트가 시간을 보낼 것 같아 무서웠다. 아무래도 배포후 사람들이 많이 쓰는 시점에 도입을 고려하는게 좋을 것 같다는 생각이 든다. 

 

3. 새롭게 시작할 때 사용하기

 

마이그레이션은 어렵지만 그래도 Hilt는 Dagger가 갖고 있던 단점을 보완한 훌륭한 라이브러리라는 점은 변함이 없다. 지금 당장 도입하기는 어렵지만 부담 없는 사이드 프로젝트에서 적용하면서 경험해보고 많은 사람들이 사용하면서 스택 오버플로우에 정보가 많아질 때 쯤 도입을 해본다면 괜찮을 것 같다.

 

728x90

 

현재 안드로이드 의존성 주입 라이브러리로는 2017년도 Jetpack에서 소개된 Dagger가 가장 유명하다. Dagger는 컴파일타임에 의존성 여부를 판단하는 방식으로 빌드 시간만 조금 길어지는 단점만 제외하면 성능적인 이슈가 없고 자유자재로 의존성을 관리할 수 있어 가지고 있는 기능만 본다면 완벽한 것 같았으나 클래스 하나를 Inject 시키기 위해 너무 많은 Boiler Plate 코드를 만들어야 하며 프로젝트에 도입하기 전에 공부해야 할 게 너무 많아 지치고 바쁜 클라이언트 개발자들이 당장 사용하기엔 불편하다는 피드백을 많이 받았다. 구글에서는 이런 불편사항들을 반영해 개발자들이 좀 더 쉽게 쓸 수 있는 Hilt라는 것을 만들었다.

 

Dagger를 기반으로 만든 라이브러리기 때문에 Dagger의 장점인 컴파일 타임의 의존성 체크, 자유로운 의존성 주입, 성능상의 이점은 그대로 가져가고 주안점으로 둔 Boiler Plate 코드 생성 작업은 최소화 시켰다. 최근에 시간이 생겨서 구글에서 제공하는 예제를 직접 구현하면서 따라가봤는데 Dagger에서 번거롭거나 불필요하다고 느꼈던 코드들이 Hilt를 사용하면서 많이 줄어들게 됐고 필요하다고 느꼈던 기능이 도입돼서 앞으로 많은 개발자들이 사용하게 되지 않을까 싶다. 모든 개선 사항들에 대해서는 공식 문서를 참고하면 좋을 것 같고 이 포스트에서는 내가 주의깊게 보고 있는 대표적인 개선사항 몇가지 다뤄볼려고 한다. 

 

 

 

 

1. Compontent 인터페이스가 사라짐

 

Dagger에서는 AppComponent 인터페이스를 만들어서 DaggerAppComponent 클래스를 자동생성 했다. 이 클래스로 Application 클래스에서 Dagger를 사용하도록 설정하고 주입시킬 모듈을 등록할 수 있었다.

 

Dagger Code

@Singleton
@Component(
    modules = [
        AndroidSupportInjectionModule::class,
        AppModule::class,
        ActivityBuildersModule::class,
        FragmentBuildersModule::class
    ]
)
interface AppComponent: AndroidInjector<BaseApp> {
    @Component.Builder
    abstract class Builder : AndroidInjector.Builder<BaseApp>()
}

class BaseApp: DaggerApplication() {
    override fun applicationInjector(): AndroidInjector<out DaggerApplication> {
        return DaggerAppComponent.builder().create(this)
    }

 

Hilt에서는 @HiltAndroidApp 어노테이션만 추가하면 이 앱은 Hilt 라이브러리를 사용하는 것으로 설정 할 수 있다.

 

Hilt Code

@HiltAndroidApp
class MainApp: Application() {}

 

2. Activity, Fragment에 대한 Dagger 모듈을 생성할 필요가 없어짐.

 

Activity, Fragment 같은 Lifecycle 클래스에서 Dagger를 쓰려면 아래처럼 일일이 모듈에다가 선언을 해줬어야 했었다. 새로운 화면을 만들 때마다 생성해야해서 정말 번거로운 작업이었다.

 

Dagger Code

@Module
abstract class DaggerFragmentModule {
    @Module
    abstract class StartModule {
        @Binds
        @FragmentScope
        abstract fun provideFragment(fragment: StartFragment): Fragment
    }

 

이제는 Fragment 클래스 위에  @AndroidEntryPoint 어노테이션만 붙여주면 된다. 이 클래스에 대해서는 의존성 주입 작업을 넣겠다는 뜻이 된다.

 

Hilt Code

@AndroidEntryPoint
class LogsFragment : Fragment() {

 

3. 모든 모듈은 자동 빌드

 

Dagger에선 모듈 클래스는 Dagger 라이브러리로 빌드하려면 최종적으로 AppComponent의 모듈에 등록해야했다. 아래 코드에선 SystemModule -> AppModule -> AppComponent 로 포함관계로 SystemModule이 적용된다. 아래 코드만 보면 별거 아니긴 하지만 은근히 깜빡하는 경우가 많아 빌드할 때 빨간색 에러를 자주 뿜던 곳이었다.

 

Dagger Code

@Singleton
@Component(
    modules = [
        AndroidSupportInjectionModule::class,
        AppModule::class,
        ActivityBuildersModule::class,
        FragmentBuildersModule::class
    ]
)
interface AppComponent: AndroidInjector<BaseApp> {

@Module(includes = [SystemModule::class])
abstract class AppModule {
    @Binds
    @Singleton
    abstract fun bindContext(application: BaseApp): Context
}

@Module
class SystemModule {
    @Provides
    fun provideContentResolver(context: Context): ContentResolver {
        return context.contentResolver
    }
}

 

그런데 Dagger에서는 이런 너저분한(?) 포함관계는 안만들어도 되고 @Module 어노테이션 앞에 @InstallIn 어노테이션만 추가해주면 알아서 빌드가 된다. 

 

Hilt Code

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {
    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

 

4. Application, Activity, Fragment 범주 선언이 쉬워짐

 

앞서 3에서 나온 코드에 @InstallIn 어노테이션을 활용하면 내가 주입할 클래스가 Application 범위인지, Activity 범위인지, Fragment 범위인지를 쉽게 표현해줄 수 있다. 이렇게 범위를 잡아주면 Hilt에서는 주입할 때 해당 라이프사이클 클래스에 맞는 객체를 생성해서 넣게 된다.

 

Hilt Code

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {
    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {
    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

@AndroidEntryPoint
class ButtonFragment: Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource // application component
    @Inject lateinit var navigator: AppNavigator // activity component
728x90
  1. alpacino609 2021.03.23 14:00  댓글주소  수정/삭제  댓글쓰기

    이런게 있었네요^^글 감사합니다. 그런데 3번에 두번째 예제는 dagger가 아니라 hilt라는 말씀이죠?

RxJava - debounce

개발/안드로이드 2020. 7. 4. 15:17 Posted by 아는 개발자

 

debounce 는 특정 시간이 지난 후에 마지막으로 들어온 이벤트만 받을 수 있는 오퍼레이터다. 글로 구구 절절 설명하는 것보다는 아래 그림으로 이해하는게 쉬울 것 같은데 아래 그림에서 최종적으로 받는 이벤트가 1, 5, 6인데 모두 입력됐을 때랑 입력으로 들어올 때랑 배출 될 때 사이의 구간 길이가 동일하다. 아래 그림에서 길이는 시간을 의미하기 때문에 둘다 동일한 시간이 지난 후에 이벤트를 받는 것으로 보면 된다. 그리고 5 이벤트가 들어오기 전에 2, 3, 4가 있었는데 최종적으로 5만 이벤트로 받게 되는데 이건 특정 시간이 지나기 전에 다른 이벤트들이 들어와서 그렇다. 그래서 2, 3, 4 이벤트는 모두 무시하게 된다.

 

 

debounce 오퍼레이터는 텍스트 입력과 관계된 작업에서 아주 요긴하게 써먹을 수 있다. 대표적으로 요즘 검색창 같은데선 유저의 입력을 받고 예상되는 입력 결과를 보여주는 UX가 많은데 매번 탕핑 할 때마다 서버를 치는것이 부자연스럽고 부하도 크다. 대체적으로 1초간 아무런 입력이 없었을 때 서버를 치도록 규칙을 넣어 놓는데 이때 debounce를 사용하면 쉽게 구현할 수 있다.

 

이 포스트에서는 서버를 치는 것은 없고 타이핑 한 내용이 1초 뒤에 TextView에 업데이트 되는 것으로 debounce 사용 예제를 만들어봤다. text watcher를 등록해서 타이핑한 내용이 변할 때마다 textChange 오브젝트에 이벤트를 보내고 debounce를 1초 걸어서 1초를 기다린 다음 마지막으로 들어온 스트링 값에 대해서 화면에 표현하도록 구현했다.

 

val textChange: PublishSubject<String> = PublishSubject.create()

fr_rx_edit.addTextChangedListener(object: TextWatcher{
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
        textChange.onNext(s.toString())
    }
    override fun afterTextChanged(s: Editable?) {}
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
})

textChange
    .debounce(1, TimeUnit.SECONDS)
    .observeOn(AndroidSchedulers.mainThread())
    .doOnNext {
        fr_rx_tv.text = it
    }
    .subscribe()

 

결과 아래 그림처럼 타이핑 후 1초를 기다린 다음에 화면에 업데이트 된다.

 

728x90

RxJava - observeOn, subscribeOn

개발/안드로이드 2020. 6. 28. 18:00 Posted by 아는 개발자

 

RxJava의 observeOn, subscribeOn 함수를 이용하면 실행할 코드의 쓰레드 종류를 설정해줄 수 있다. 안드로이드의 경우 로컬 데이터 DB에 접근할 때는 IO 쓰레드를 사용해야하고 화면을 업데이트 할 때는 UI 쓰레드를 사용해야하며 이를 어기면 파란색 에러를 내뿜으면서 작업을 실행하지 않는데 RxJava의 observeOn, subscribeOn 함수를 사용하면 여기서 발생하는 오류들을 쉽게 해결할 수 있다.

 

일반적으로 subscribeOn 함수는 호출 시점 상위에 해당하는 부분의 쓰레드를, observeOn은 호출시점 하위 스트림의 쓰레드를 설정한다. 아래 코드 Single.fromCallable과 map 함수가 IO thread에서 실행되게 되고 하위 doOnSuccess와 doOnError는 mainThread를 따르게 된다. UI와 무관한 데이터 전처리 작업은 IO thread에서 실행하도록 설정하고 그 이후 화면을 업데이트할 때 사용하는 코드는 mainThread에 둔다면 동작이 스무스한 애플리케이션을 만들 수 있다.

 

private fun getLong(): Single<Long> = Single.fromCallable {
    1L // IO thread
}
    .map { it + 10 } // IO thread
    .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread())
    .doOnSuccess {  } // main thread
    .doOnError {  } // main thread

 

subscribeOn은 상위 스트림 쓰레드를 결정하지 못하는 경우가 있다. 스택오버플로우에서는 이런 경우가 언제 발생하는지 그리고 어떻게 변경할 수 있는지 질의응답이 있는데 나는 개인적으로 일반적인 경우에서 벗어난 일이 발생한다면 나는 스트림을 잘못 구현한 것이라고 본다. 이럴 때는 너무 긴 스트림을 잘게 쪼개서 일반적인 경우를 따르도록 만드는게 좋다고 생각한다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

Hilt - Dagger를 이을 의존성 주입 라이브러리(1)  (2) 2020.07.08
RxJava - debounce  (0) 2020.07.04
RxJava - observeOn, subscribeOn  (0) 2020.06.28
RxJava - combineLatest  (0) 2020.06.28
status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24

RxJava - combineLatest

개발/안드로이드 2020. 6. 28. 17:44 Posted by 아는 개발자

 

combineLatest 함수는 모든 Observable 소스에서 이벤트를 받은 후 마지막으로 받은 소스의 시점에서 새로운 이벤트를 만들고 싶을 때 사용한다. 아래 그림을 보면 두개의 소스를 받고 있고 각각 마지막으로 추가된 작업에 대해서 마다 새로운 이벤트를 형성해주고 있다.

 

 

위와 같은 구조는 모든 데이터를 받고 난 다음에 한번에 업데이트 하고 싶은 경우나 하나의 소스가 변경될 때마다 최종적으로 입력된 Observable 소스를 불러오고 싶은 경우 유용하다. 추상적으로 서술해서 감이 잘 오지 않는데 각 경우의 예를 생각해보면 이해하기 쉬울 것 같다. 

 

첫번째의 예로는 서버에서 받은 정보를 앱의 화면에 노출할 때로 볼 수 있을 것 같다. 컨텐츠 페이지에서 페이지의 정보와 댓글 리스트를 화면에 뿌려줘야 하는데 이 리스트가 다른 api로 분기되어 있다면 각각을 분리해서 호출해야 하고 화면 업데이트도 한번에 이뤄지지 않아 따닥따닥 이뤄질 수 있다. 이렇게 분리된 경우는 로딩 아이콘 표시하는 작업도 각 호출별로 설정해줘야해서 번거로운데 이럴 때는 combineLatest 구조를 이용해서 모든 소스를 받고 난 다음에 업데이트 하도록 두 소스를 묶어줄 수 있다.

 

코드로 표현하면 이렇다. Flowable의 combineLatest 함수로 묶어서 댓글과 컨텐츠 리스트를 Flowable 소스로 받고 모든 소스가 도달 했을 때 loading 이 종료되었음을 false로 알릴 수 있다.

 

Flowable.combineLatest(
    getReplyList(),
    getContentList(),
    BiFunction<List<String>, List<String>, Pair<List<String>, List<String>>> {
        replyList, contentList ->
        Pair(replyList, contentList)
    }
)
    .doOnNext { loading.onNext(false) }
    .doOnNext { pair -> 
        val replyList = pair.first
        val conentList = pair.second
    }
    .subscribe()
    
private fun getReplyList(): Flowable<List<String>> = Flowable.fromCallable {
    listOf("Hello", "World", "RxJava", "Flowable")
}

private fun getContentList(): Flowable<List<String>> = Flowable.fromCallable {
    listOf("This", "is", "developer")
}

 

두번째 경우로는 웹사이트에서 로그아웃이 된 경우 새로고침시 파라미터로 넣을 정보가 Observable 소스로 된 경우다. 로컬 저장소에 유저의 id를 이용해 로그인 유무를 판단하고 유저 상태 변경시 홈 api를 호출해야 하는데 이때 Observable 소스로 된 로컬 토큰 정보가 필요하면 id 가 변경될 때마다 token 정보를 새롭게 받도록 combineLatest로 묶어줄 수 있다. 코드로 표현하면 아래와 같다. 

 

Flowable.combineLatest(
    id().distinctUntilChanged(),
    token(),
    BiFunction<Long, String, Pair<Long, String>> { id, token ->
        Pair(id, token)
    }
)
    .flatMap { pair -> 
        val id = pair.first
        val token = pair.second
    
        home(id, token)
    }
    .subscribe()

 

combineLatest로 묶을 수 있는 소스의 개수는 최대 9개까지 가능하다. 그런데 이정도로 많은 소스를 combineLatest로 묶는 경우는 거의 희박할 뿐더러 관리하기도 어렵다. 가능하면 3, 4개 까지 사용하는게 코드 유지 관리 측면에서도 좋다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

RxJava - debounce  (0) 2020.07.04
RxJava - observeOn, subscribeOn  (0) 2020.06.28
RxJava - combineLatest  (0) 2020.06.28
status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21

status bar 영역 덮는 view 만들기

개발/안드로이드 2020. 6. 24. 22:57 Posted by 아는 개발자

 

안드로이드 런처 화면 상단을 보면 status bar 영역을 배경 영역이 덮고 있는 것을 볼 수 있다. 이렇게 status bar 영역까지 확장할 경우 유저에게 꽉찬 느낌을 줄 수 있어서 틱톡을 비롯한 몇몇 앱에서도 활용하고 있다. 이번 포스트에서는 이렇게 status bar 영역까지 확장하는 방법을 소개한다.

 

 

1. AppTheme 설정

 

windowActionBar, windowNoTitle 속성을 추가하고 이 style 값을 activity에서 사용하도록 한다.

 

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
	<!-- Customize your theme here. -->
	<item name="colorPrimary">@color/colorPrimary</item>
	<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
	<item name="colorAccent">@color/colorAccent</item>

	<item name="windowActionBar">false</item>
	<item name="windowNoTitle">true</item>
</style>

 

2. FullScreen 형태로 변경 

 

activity 또는 fragment에서 현재 window의 systemUiVisibility 플래그 값을 View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 로 변경하고 statusBarColor를 투명으로 바꿔준다. 

this.window?.apply {
    this.statusBarColor = Color.TRANSPARENT
    decorView.systemUiVisibility =
        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

}

 

이렇게 두가지 작업만 적용해도 아래 처럼 status bar 영역까지 차지하게 되는 것을 볼 수 있다. 그런데 앱에서 차지하고 있는 공간이 거의 흰색에 가깝다 보니까 status bar 아이콘들이 잘 보이지 않는 문제점이 있다. 

 

3. Status Bar Icon 색 변경

 

이런 경우에 직접 status bar 아이콘의 색깔을 어둡게 설정해줄 수 있다. View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR 플래그를 추가하면 아이콘이 light 배경일 때 사용할 수 있도록 어두운 아이콘으로 나오게 된다.

 

this.window?.apply {
    this.statusBarColor = Color.TRANSPARENT
    decorView.systemUiVisibility =
        View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

}

 

적용하면 아래의 그림처럼 아이콘 색이 변한다

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

RxJava - observeOn, subscribeOn  (0) 2020.06.28
RxJava - combineLatest  (0) 2020.06.28
status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24

Lottie 라이브러리

개발/안드로이드 2020. 6. 24. 19:18 Posted by 아는 개발자

 

서비스 출시 막판 디자인 작업에선 다이나믹하고 아기자기한 애니메이션 효과를 주고 싶어하는 디자이너와 물리적 효과 구현의 어려움과 잠재적인 에러 때문에 주저하는 개발자 사이에 보이지 않는 갈등이 존재하곤 했었는데, 손쉽게 애니메이션 효과를 줄 수 있는 Lottie 라이브러리가 나오면서 조금이나마(?) 해소됐다. Lottie 라이브러리는 기존 Animator 클래스에 있는 함수와 속성 변수를 거의 그대로 가져다 쓰면서도 애니메이션 효과로 디자이너 분이 작업해둔 json 파일만 붙여두면 되기 때문에 사용하기가 정말 쉽다. 이번 포스트에서는 lottie json 파일을 안드로이드에서 사용하는 방법에 대해서만 간단히 소개하려고한다.

 

1. 라이브러리 임포트 

 

현재 최신 버전은 3.4.0 까지 릴리즈 됐다. build.gradle에 추가해준다

dependencies {
  implementation 'com.airbnb.android:lottie:$lottieVersion'
}

 

2. XML 파일에 Lottie 추가

 

새롭게 추가된 속성은 app:lottie_fileName 정도다. 여기에 Lottie 애니메이션 효과를 줄 파일을 넣으면 된다. 이 파일은 프로젝트에 app/src/main/assets 에 넣어두면 된다. 여기에 안넣어두면 이 파일 못찾는다고 앱이 크래쉬 날 수 있으니 주의하자.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <com.airbnb.lottie.LottieAnimationView
        android:id="@+id/fr_lottie_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:lottie_fileName="img_lottie.json"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

 

3. 자바 파일에서 애니메이션 실행 

 

앞서 XML 파일에서 선언해둔 뷰 오프젝트를 직접 실행 해주면 된다. 상황에 따라서 어느 시점에 이 애니메이션을 실행할지 결정할 수 있다. 메인쓰레드에서 playAnimation() 함수만 실행하면 자동적으로 동작한다.

 

class LottieFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_lottie_view.playAnimation()
    }

실행해보면 아래 gif 처럼 실행된다.

4. 여러가지 옵션 

 

단순히 애니메이션을 실행하는 것 뿐만 아니라 여러가지 옵션을 줄 수 있다. 옵션도 무수히 많은데 자주쓰는 것들만 소개해보면 이런 것들이 있다.

 

4.1 무한 재생하기

 

애니메이션을 몇번 재생할지 결정하는 변수인데 이 값을 -1로 주면 무한대로 재생하게 할 수 있다. 로딩 애니메이션을 표현할때 유용하다

 

fr_lottie_view_infinite.apply { repeatCount = -1 }

 

4.2 거꾸로 재생하기

 

애니메이션 스피드를 조정할 수 있는 변수인데 이 값을 -1f로 설정하면 거꾸로 재생할 수 있다. 값은 -1f ~ 1f 사이이고 물론 속도도 조절 가능 하다.

fr_lottie_view_reverse.apply { speed = -1f }

 

두 효과를 적용한 결과 아래 그림처럼 하나는 무한대로, 다른 하나는 반대로 돌아가게 된다

728x90

'개발 > 안드로이드' 카테고리의 다른 글

RxJava - combineLatest  (0) 2020.06.28
status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24

MediaCodec - Encoding

개발/안드로이드 2020. 6. 21. 19:48 Posted by 아는 개발자

 

디코딩이 비디오 정보를 분해하는 작업이었다면 인코딩은 역으로 새로운 비디오를 만드는 작업이다. 포토샵이나 비타 같은 비디오 에디터를  이용해 기존의 비디오의 화질을 줄이고 영상에 자막을 입히거나 스티커를 붙이는 작업 모두 인코딩 작업의 일환이라고 볼 수 있다. 비디오 파일마다 갖고 있는 고유의 속성인 FPS, Bitrate, 가로 세로 크기 모두 인코딩 작업에서 설정된다.

 

앞서 말한 것 처럼 인코딩 작업은 광범위한데 이번 포스트에서는 인코딩의 가장 기본적이라 할 수 있는 예제인 기존에 디코딩한 비디오를 다시 똑같은 비디오로 인코딩하는 작업으로 설명해보려고 한다. 앞선 포스트에서 인코딩에 대해 설명했었는데 인코딩에서도 디코딩과 비슷한 작업이 많아 중복되는 내용에 대해서는 생략하고 이 포스트의 주제인 인코딩에 대해서 중점적으로 설명하려고 한다. 예제코드로 구글 미디어코덱 CTS 에 사용한 코드를 참고했다. 구글 코드 답지 않게 리팩토링도 덜되어있고(테스트코드니까) 알아보기가 쉽지는 않으니 이 글이 이 코드를 분석하려는 분들에게 많은 도움이 됐으면 좋겠다.

 

1. Create 

 

디코딩 작업과 비슷하게 인코딩도 MediaCodec을 이용해서 인코딩을 담당할 객체를 생성한다. MediaCodec.createByCodecName() 을 통해 객체를 생성하고 configure를 통해 구체적인 정보를 설정한다. 옛날 코드에서는 createByCodecName에 넣는 인자는 인코딩해서 생성할 비디오의 압축 방식을(대표적으로 mpeg인 video/avc 가 있다) 설정한다. configure의 첫번째 인자로 outputVideoFormat이 들어가는데 outputVideoFormat의 세팅 작업을 보면 대충 무엇을 하고 있는지 감이 올 것이다. 비디오의 비트레이트, 프레임레이트를 설정해주는 단계다. 이렇게만 설정을 해주면 알아서 이 값에 맞게 비디오가 만들어진다.

 

outputVideoFormat.setInteger(
	MediaFormat.KEY_COLOR_FORMAT, OUTPUT_VIDEO_COLOR_FORMAT);
outputVideoFormat.setInteger(MediaFormat.KEY_BIT_RATE, OUTPUT_VIDEO_BIT_RATE);
outputVideoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, OUTPUT_VIDEO_FRAME_RATE);
outputVideoFormat.setInteger(
	MediaFormat.KEY_I_FRAME_INTERVAL, OUTPUT_VIDEO_IFRAME_INTERVAL);
if (VERBOSE) Log.d(TAG, "video format: " + outputVideoFormat);
// Create a MediaCodec for the desired codec, then configure it as an encoder with
// our desired properties. Request a Surface to use for input.
AtomicReference<Surface> inputSurfaceReference = new AtomicReference<Surface>();
videoEncoder = createVideoEncoder(
	videoCodecInfo, outputVideoFormat, inputSurfaceReference);
    
private MediaCodec createVideoEncoder(
    MediaCodecInfo codecInfo,
    MediaFormat format,
    AtomicReference<Surface> surfaceReference)
    throws IOException {
    MediaCodec encoder = MediaCodec.createByCodecName(codecInfo.getName());
    encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
    // Must be called before start() is.
    surfaceReference.set(encoder.createInputSurface());
    encoder.start();
    return encoder;
}

 

2. Handle Buffer

 

2.1 Get Input Buffer

 

디코딩 작업과 비슷하게 버퍼를 처리하는 루틴을 가진다. 디코딩에서는 비디오 파일을 뽑아와서 OutputSurface 와 같은 뷰에 넣어주었다면 인코딩 작업에서는 새롭게 비디오로 만들 비디오 프레임 버퍼 정보를 핸들링 하게 된다. 인코더가 버퍼를 받아오는 부분은 코드로 바로 설명하는 것은 어려우니 먼저 아래 그림을 참고하도록 하자.

 

 

분석이 쉽지 않았다

 

 

인코더는 Surface를 통해서 비디오에 인코딩할 버퍼 정보를 받아오게 된다. 아래 그림 오른쪽 상단위 InputSurface가 인코더에 넣을 정보를 전송하는 곳이다. 전반적인 흐름을 설명하자면 디코더에서 받아온 정보는 잘게잘게 쪼게져서 OutputSurface로 이동하고 이 정보를 OutputSurface 내의 객체에서 호출한 OpenGL 코드를 통해 OpenGL Thread 메모리 영역에 저장한다. 여기서 그려지는 정보는 TextureRender를 거쳐서 인코더가 버퍼를 받을 수 있도록 매핑된 객체는 InputSurface로 이동하게 되는데 인코더는 여기서 받아온 정보를 MediaMuxer를 통해서 비디오 파일을 생성하게 된다.

 

아래 코드는 위 그림에서 인코더에게 버퍼를 전달하는 부분만 추출한 것이다. drawImage는 현재 디코더에서 받아온 정보를 실제 그림으로 그리는 코드다. 이 그림 버퍼는 앞서 설명한 것 처럼 고유한 OpenGL Thread 메모리 영역에 저장된다. 바로 다음에 이뤄지는 setPresentationTime 함수는 현재 프레임 버퍼가 차지하게 되는 시간대를 설정하는 함수다. 디코더 정보에 마이크로 세컨드 정보가 포함되어 있어서 이 정보를 통해 어디에 위치해야할 지 알 수 있다. 최종적으로 swapBuffers를 통해서 버퍼 정보를 인코더에 전달한다.

 

if (VERBOSE) Log.d(TAG, "output surface: await new image");
outputSurface.awaitNewImage();
if (VERBOSE) Log.d(TAG, "output surface: draw image");
outputSurface.drawImage();
inputSurface.setPresentationTime(
	videoDecoderOutputBufferInfo.presentationTimeUs * 1000);
if (VERBOSE) Log.d(TAG, "input surface: swap buffers");
inputSurface.swapBuffers();
if (VERBOSE) Log.d(TAG, "video encoder: notified of new frame");

 

* 여기서 OpenGL에 해당하는 클래스와 함수는 설명을 생략했다. 기초적인 OpenGL 지식이 있어야하고 설명하려면 밑도 끝도 없을 것 같아서.... 무엇보다 필자가 아직 OpenGL을 잘 모르는 것이 문제다. 간단한 정보만 설명하자면 CTS 코드에서는 받아온 비디오 정보랑 똑같이 입력할 수 있도록 구현해둔 상태다. 여기 있는 값을 잘만 이용하면 비디오에 자막과 워터 마크도 입힐 수 있고 비디오 크롭, 스케일 값도 조정하는 이펙트도 넣을 수 있다. 이런 기능을 구현해보고 싶으신 분은 OpenGL 코드를 공부해보면 좋을 것 같다.

 

* 꼭 OpenGL을 이용해서 정보를 전달하지 않는 방법도 있다. 대표적으로 오디오에서는 디코더에서 직접 인코더의 InputBuffer에 값을 넣어준다. 예제로 사용한 파일에서 오디오에대한 인코딩 작업도 있으니 관심있는 분은 참고해보시길!

 

2.2 Handle Buffer 

 

버퍼를 처리하는 부분은 디코더랑 꽤 비슷하다. 디코더에서는 빼낸 정보를 output surface에다가 넣었다면 인코더에서는 비디오를 생성할 수 있는 muxer라는 객체에 넣는 점만 다르다. 코드를 한번 살펴보자. swapBuffers로 넘어온 정보는 videoEncode Output 버퍼에 쌓여 있는데 dequeueoutputBuffer를 통해서 이 정보가 저장된 인덱스 정보를 얻어오고 인덱싱을 통해 ByteBuffer로 구체적인 정보를 받아온다. 디코딩처럼 Index 정보가 유효하지 않는 경우에는 무시하고 작업을 진행하도록 한다. 

 

받아온 ByteBuffer에는 버퍼의 크기와 플래그가 포함되어 있는데 플래그 비트 중 CODEC_CONFIG 값이 포함되면 무시하도록 한다. 이 값을 muxer에 포함시키면 비디오가 실행이 안되니 주의하도록 하는게 좋다. 사이즈 값이 0이 아니라면 비디오에 포함될 수 있는 유효한 정보라고 본다. writeSampleData 함수를 통해 인코딩할 비디오 정보를 넣은 후 버퍼 메모리를 release 해준다.

 

int encoderOutputBufferIndex = videoEncoder.dequeueOutputBuffer(
	videoEncoderOutputBufferInfo, TIMEOUT_USEC);
if (encoderOutputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
	if (VERBOSE) Log.d(TAG, "no video encoder output buffer");
		break;
}
if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
	if (VERBOSE) Log.d(TAG, "video encoder: output buffers changed");
	videoEncoderOutputBuffers = videoEncoder.getOutputBuffers();
	break;
}
if (encoderOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
	if (VERBOSE) Log.d(TAG, "video encoder: output format changed");
	if (outputVideoTrack >= 0) {
		fail("video encoder changed its output format again?");
	}
	encoderOutputVideoFormat = videoEncoder.getOutputFormat();
	break;
}

ByteBuffer encoderOutputBuffer = videoEncoderOutputBuffers[encoderOutputBufferIndex];
if ((videoEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG)	!= 0) {
	// Simply ignore codec config buffers.
	videoEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
	break;
}
if (videoEncoderOutputBufferInfo.size != 0) {
	muxer.writeSampleData(
		outputVideoTrack, encoderOutputBuffer, videoEncoderOutputBufferInfo);
}
if ((videoEncoderOutputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM)!= 0) {
	if (VERBOSE) Log.d(TAG, "video encoder: EOS");
	videoEncoderDone = true;
}
videoEncoder.releaseOutputBuffer(encoderOutputBufferIndex, false);
videoEncodedFrameCount++;

 

2.3 Muxer 

 

Muxer에 대한 설명을 빼뜨렸는데, MediaMuxer는 새로운 비디오를 만들어줄 수 있는 클래스다. 첫번째 인자로 비디오로 생성될 파일의 경로와 이름을 파일 클래스를 통해서 넣어주고 두번째 인자로 생성될 비디오 파일의 확장자(mp4)를 설정해준다. 이것만 해주면 된다. 이렇게 선언만 해두고 인코더에서 받아온 버퍼 정보를 writeSampleData로 넣어주기만 하면 된다.

private MediaMuxer createMuxer() throws IOException {
	return new MediaMuxer(mOutputFile, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
}

 

2.4 release 

 

디코딩과 마찬가지로 인코딩에 사용한 작업들도 release 해주는 단계가 필요하다. muxer와 encoder 객체 뿐만 아니라 인코더에 버퍼를 전달할 때 사용한 inputSurface까지 해줘야한다. 안해준다고 비디오가 안생성되는 것은 아니지만 메모리 정보는 잊지 않고 해제해주는것이 좋다.

 

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

status bar 영역 덮는 view 만들기  (0) 2020.06.24
Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20

MediaCodec - Decoding

개발/안드로이드 2020. 5. 24. 13:09 Posted by 아는 개발자


MediaCodec을 사용하기 위해선 기본적인 비디오 영상 처리에 대한 지식이 필요하다. 영상처리도 깊이 들어가면 한도 끝도 없을 것 같은데 이 포스트에서는 MediaCodec 을 이용해 Decoding 할 때 반드시 알고 있어야 하는 지식 정도로만 간추려서 소개하려고 한다.

 

여기서 사용한 예제 코드는 grafika https://github.com/google/grafika 저장소의 MoviePlayer.java 코드에서 deprecated 된 부분만 바꿨다. 실제로 동작하는 코드를 확인하고 싶다면 여기서 프로젝트를 받아 실행해보면 될 것 같다

 

1. Definition

 

Decoding은 영상 파일이 가지고 있는 정보를 추출해내는 작업이다. 우리가 흔히 볼 수 있는 영상플레이어들은 모두 비디오 파일을 읽고 이 정보를 화면에 뿌리는 디코딩 작업을 거친다. 디코딩 작업에는 화면 프레임 정보 뿐만 아니라 비디오가 가지고 있는 음성 파일도 포함한다. 영상에서 긁어온 정보들을 컨트롤해서 화면에 뿌려주는게 영상플레이어의 역할이다. 안드로이드에서는 MediaCodec 라이브러리를 통해 영상과 음성에 대해서 디코딩을 할 수 있다.

 

2. Create

 

MediaCodec 라이브러리를 이용해서 디코딩 작업을 담당하는 객체를 생성할 수 있다. 아래 코드에서 createDecoderByType 함수가 Decoder를 생성하는 함수다. 생성 전에 수행하는 작업을 볼 필요가 있는데, 앞의 MediaExtractor 클래스가 하는 역할은 소스 파일에서 비디오의 Meta 정보를 가져오는 역할을 한다. 비디오가 가지고 있는 Meta 정보로는 비디오의 bitrate, width, size 등등을 가져올 수 있는데 디코딩 작업을 할때는 비디오의 현재 압축 방식인 MIME이 필요하다. 이 압축방식은 비디오의 확장자마다 다른데 거의 모든 영상을 mp4로 담고 있는 현재는 대부분 h264 방식을 따르고 있다. 정확히 이게 어떤 방식으로 압축되는지는 나도 잘 모른다. 아무튼 이 정보를 통해 비디오 디코더를 읽어올 수 있다.

 

extractor = new MediaExtractor();
extractor.setDataSource(mSourceFile.toString());
int trackIndex = selectTrack(extractor);
if (trackIndex < 0) {
	throw new RuntimeException("No video track found in " + mSourceFile);
}
extractor.selectTrack(trackIndex);

MediaFormat format = extractor.getTrackFormat(trackIndex);

// Create a MediaCodec decoder, and configure it with the MediaFormat from the
// extractor.  It's very important to use the format from the extractor because
// it contains a copy of the CSD-0/CSD-1 codec-specific data chunks.
String mime = format.getString(MediaFormat.KEY_MIME);
decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(format, mOutputSurface, null, 0);
decoder.start();

 

decoder.configure 함수의 첫번째 인자로는 아까 추출한 압축 방식을 전달했고 두번째 인자로 mOutputSurface라는 값을 전달하고 있다. mOutputSurface는 decoder로 받은 정보를 화면에 뿌려줄 도화지 역할을 하는데 위와같이 configure함수에 두번째 인자로 넣으면 디코딩 된 정보를 자동으로 화면에 뿌려줄 수 있게 된다. 

 

2. Extract Data

 

다음으로 할 작업은 디코더를 이용해 실제로 비디오 파일에서 영상 정보를 추출하는 것이다. 아래 그림은 MediaCodec에 관한 구글 개발자 문서에서 가져온 것인데, 코덱의 일종인 디코더는 영상을 가져올때 크게 input 작업과 output 작업을 거친다. 초록색으로 칠해진 클라이언트는 비디오에서 정보를 읽는 작업이고, 작은 정사각형을 채워넣는 작업은 앞서 클라이언트에서 읽어온 정보를 디코더 버퍼에 읽어온 채워넣는 작업이다. 이것 모두 개발자가 해야하는 일이다. 이 작업도 아래의 그림처럼 크게 두가지 단계로 정리해볼 수 있을 것 같다.

 

 

 

2.1 Input Phase

 

비디오에서 읽어온 정보를 Input 버퍼에 채워넣는 일이다. 코드를 하나하나 살펴보자. dequeueInputBuffer 함수는 받아온 나중에 받아올 정보를 채워너 넣을 수 있는 공간을 할당받는 함수다. 리턴값으로 index를 주는데 이 index 값은 엄청 길다란 배열의 index 값으로 생각하면 된다. 이 index 값을 받아서 데이터를 쓸 위치를 넣을 수 있다.

 

다음 작업으로는 비디오에서 데이터를 읽어오는 작업이다. 여기서 사용된 extractor 변수는 앞서 생성 작업에서 선언한 변수와 동일하다. 객체 내에 읽은 부분에 대한 iterator가 포함되어 있어서 어디까지 읽었는지 정보를 담고 있다. 코드 맨 마지막에 advance 함수를 통해 읽을 위치를 변경하는 것이 가능하다.

 

마지막으로 extractor에서 뽑아온 정보를 Input 버퍼에 넣어야한다. 앞어 읽어온 sample data의 리턴 값에 따라서 Input 버퍼에 넣는 정보가 다른데 이 값이 마이너스인 경우에는 모든 데이터를 읽은 경우이기 때문에 Input Buffer에 플래그 값으로 END_OF_STREAM을 넣어준다. 그 외의 경우에는 유효한 데이터인 것으로 보고 Input Buffer에 넣고 플래그 값을 0으로 넣는다. 함수의 네번째 인자로 시간 정보 값을 주는데 이 값은 현재 읽어온 버퍼가 비디오에서 몇초대에 위치하고 있는지에 대한 정보다.

 

int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
if (inputBufIndex >= 0) {
    if (firstInputTimeNsec == -1) {
        firstInputTimeNsec = System.nanoTime();
    }

    ByteBuffer inputBuf = decoder.getInputBuffer(inputBufIndex);
    // Read the sample data into the ByteBuffer.  This neither respects nor
    // updates inputBuf's position, limit, etc.
    int chunkSize = extractor.readSampleData(inputBuf, 0);
    if (chunkSize < 0) {
        // End of stream -- send empty frame with EOS flag set.
        decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L,
                MediaCodec.BUFFER_FLAG_END_OF_STREAM);
        inputDone = true;
        if (VERBOSE) Log.d(TAG, "sent input EOS");
    } else {
        if (extractor.getSampleTrackIndex() != trackIndex) {
            Log.w(TAG, "WEIRD: got sample from track " +
                    extractor.getSampleTrackIndex() + ", expected " + trackIndex);
        }
        long presentationTimeUs = extractor.getSampleTime();
        decoder.queueInputBuffer(inputBufIndex, 0, chunkSize,
                presentationTimeUs, 0 /*flags*/);
        if (VERBOSE) {
            Log.d(TAG, "submitted frame " + inputChunk + " to dec, size=" +
                    chunkSize);
        }
        inputChunk++;
        extractor.advance();
    }
} else {
    if (VERBOSE) Log.d(TAG, "input buffer not available");
}

 

2.2 Output Phase

 

Output Phase에서는 방금전 Input Phase에서 넣어둔 input buffer 정보를 추출하는 일을 한다. 각 비즈니스 로직에 따라서 추출한 정보를 화면에 뿌려주기도 하고 아니면 새로운 비디오를 만드는 작업으로 사용할 수도 있을 것 같다. dequeueOutputBuffer는 아까 dequeueInputBuffer 함수에서 넣어둔 정보를 가져오는 역할을 한다. 첫번째 인자는 out 타입으로 받아온 정보를 저장하고 리턴 값으로는 현재 디코더의 상태 값을 나타낸다.

 

TRY_AGAIN_LAYER는 현재 읽을 수 있는 Input buffer가 없을 때 발생한다. Input Buffer를 분명히 넣어 줬는데도 이 플래그 값이 발생하는데 input buffer를 여러차례 넣고 나면 제대로 읽을 수 있게 된다.  FORMAT_CHANGED는 디코더의 output format에 변화가 생겼다는 뜻인데 디코딩 작업에서는 딱히 중요한 점이 없다.

 

mBufferInfo 에서 받아온 정보의 플래그 값을 보게 되는데 END_OF_STREAM 가 포함되어 있으면 버퍼는 마지막인 것이다. 전에 Input Phase에서 END_OF_STREAM 플래그를 넣었던 바로 그녀석이 맞다. 

int decoderStatus = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
    // no output available yet
    if (VERBOSE) Log.d(TAG, "no output from decoder available");
} else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    MediaFormat newFormat = decoder.getOutputFormat();
    if (VERBOSE) Log.d(TAG, "decoder output format changed: " + newFormat);
} else if (decoderStatus < 0) {
    throw new RuntimeException(
            "unexpected result from decoder.dequeueOutputBuffer: " +
                    decoderStatus);
} else { // decoderStatus >= 0
    if (firstInputTimeNsec != 0) {
        // Log the delay from the first buffer of input to the first buffer
        // of output.
        long nowNsec = System.nanoTime();
        Log.d(TAG, "startup lag " + ((nowNsec-firstInputTimeNsec) / 1000000.0) + " ms");
        firstInputTimeNsec = 0;
    }
    boolean doLoop = false;
    if (VERBOSE) Log.d(TAG, "surface decoder given buffer " + decoderStatus +
            " (size=" + mBufferInfo.size + ")");
    if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
        if (VERBOSE) Log.d(TAG, "output EOS");
        if (mLoop) {
            doLoop = true;
        } else {
            outputDone = true;
        }
    }

    boolean doRender = (mBufferInfo.size != 0);

    // As soon as we call releaseOutputBuffer, the buffer will be forwarded
    // to SurfaceTexture to convert to a texture.  We can't control when it
    // appears on-screen, but we can manage the pace at which we release
    // the buffers.
    if (doRender && frameCallback != null) {
        frameCallback.preRender(mBufferInfo.presentationTimeUs);
    }
    decoder.releaseOutputBuffer(decoderStatus, doRender);
    if (doRender && frameCallback != null) {
        frameCallback.postRender();
    }

    if (doLoop) {
        Log.d(TAG, "Reached EOS, looping");
        extractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
        inputDone = false;
        decoder.flush();    // reset decoder state
        frameCallback.loopReset();
    }
}

 

doRender 변수를 결정하는 요인은 mBufferInfo.size 가 0보다 클 때 인데, 이 정보는 받아온 정보가 화면에 뿌려줄 영상 정보인지 아닌지를 의미한다. 그래서 이 값이 유효하다면 각 비즈니스 로직에 따라서 화면에 뿌려주거나 음성을 재생하면 된다. 아래 코드에서는 frameCallback 함수에서 읽어온 정보에서 시간 정보만 추출해 가져가고 있다. 경우에 따라선 비디오 디코딩 정보를 담고 있는 mOutputSurface를 OpenGL에 그려주어 인코더의 input에 넣어주기도 한다. CTS 테스트 코드를 보면 추출한 오디오 버퍼 정보를 바로 인코더에 넣는 것을 볼 수 있다.

 

읽어온 정보에 대한 처리가 끝나면 releaseOutputBuffer를 통해 이 정보에 대한 처리가 완료 됐음을 처리한다.

 

2.3 release 

 

EndOfStream에 도달해 디코딩 작업이 완료되면 사용한 Decoder를 반드시 릴리즈 시켜줘야한다. 이것은 release 함수로 가능하다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

Lottie 라이브러리  (0) 2020.06.24
MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18

MediaCodec - Getting Started

개발/안드로이드 2020. 5. 24. 10:19 Posted by 아는 개발자

 

0. FFmpeg - 한계

 

동영상과 관련된 작업을 처리하는 툴로 가장 유명한 것은 아마 FFmpeg 일 것이다. 이 라이브러리에서는 영상의 트랜스코딩(압축)을 지원할 뿐만 아니라 영상내 텍스트/이미지 삽입 또는 영상을 회전시키고 자를 수 있는 기능도 제공하며 실행도 대부분의 개발자들에게 익숙한 형태인 커맨드라인 딱 한줄만 입력하면돼 비디오에 대해서 잘 모르는 사람들도 쉽게 사용할 수 있다. 하지만 FFmpeg은 이 모든 작업들이 소프트웨어적으로 구현되어 있어 느리다는 단점이 있고 C, C++인 로우 레벨로 만들어진 빌드 파일을 JVM 위에 돌아가는 자바 언어단에서 별개의 도입하는 것은 꺼리낌이 있다. 개발 외적으로는 GPL 라이센스를 가지고 있어서 이 라이브러리의 수정사항을 공개해야한다는 법적 이슈가 있고 무엇보다 대중적으로 사용하는 h264 압축 방식을 사용하는 경우(mp4 파일을 생성하는 경우) 특허 이슈가 있다고 한다. 이 특허문제에 대해선 인터넷상에서 갑론을박이 많은데 가장 중요한 주체인 FFmpeg 공식 홈페이지에서도 "자기들은 변호사는 아니라 잘 모르겠다"고 답변하는 것으로 보아 자유롭게 사용하기에는 찝찝한 툴이다.

 

ffmpeg 공식 홈페이지 Patent issue에 대한 답변. "We do not know" 문구가 눈에 띈다.

 

1. MediaCodec - 어쩔 수 없이 써야하는 존재

 

 

플랫폼 개발자들도 이런 문제점을 인식해서인지 영상을 처리할 수 있는 고유의 라이브러리를 도입했는데 안드로이드의 경우 MediaCodec 라이브러리가 이에 해당한다. FFmpeg의 한계점을 극복하고자 도입한 라이브러리이기에 더이상 라이센스 문제도 없고 JVM위에서 동작하는 안드로이드에서 사용하기에 적합한 형태이며 하드웨어 가속을 지원해 소프트웨어적으로 돌아가는 FFmpeg보다 빠르다.

 

하지만 단점도 만만치 않다. FFmpeg은 트랜스코딩 뿐만 아니라 다양한 툴을 포함하고 있고 영상에 대해서 잘 몰라도 쉽게 사용할 수 있었다. 그러나 MediaCodec은 영상 정보를 추출하는 디코딩 작업과 바이너리 정보를 조합해 새로운 영상을 만드는 인코딩 작업만 제공할 뿐이며 원래 FFmpeg에 있었던 텍스트를 삽입하고 영상을 자르는 기능은 모두 스스로 만들어야 한다. 즉 이제는 디코딩/인코딩이 무엇인지, 영상 파일은 어떤식으로 이뤄져 있는지 그리고 텍스트를 삽입하고 영상을 자를 수 있는 그래픽의 기본 지식까지 겸비해야 한다는 뜻. 평소에 게임을 만들어본 사람이나 영상쪽에 관심있는 사람이 아니면 이쪽에 대해서 아마 잘 모를 것이다. 그리고 공부하려고 해도 진입장벽이 있는 부분이라 러닝 커브가 높다.

 

아쉽게도 단점은 이것 만이 아니다(ㅠㅠ). MediaCodec 라이브러리는 직접 하드웨어 장비와 연계된 부분이기 때문에 구글은 API만 뚫어주고 퀄컴, 삼성 LSI와 같은 칩 제조사에서 이 부분을 직접 구현했는데 이 부분이 칩(AP)에 따라서 다르다. 똑같은 갤럭시 스마트폰, 동일한 모델임에도 불구하고 국내에서 주로 사용하는 엑시노스 칩에서는 동작하는 반면 해외에서 사용하는 퀄컴 칩에서는 동작이 안될 수가 있다. 그리고 똑같은 안드로이드 버전이고 퀄컴칩을 사용하는데도 불구하고 사용하는 칩의 버전이 달라 갤럭시 노트8은 되고 갤럭시 S9은 되는 현상도 발생한다. 물론 이 경우는 코드를 잘못짠 것에 해당하기는 하나... 같은 플랫폼에서 똑같은 코드가 칩마다 다르게 동작할 수 있다는 점은 플랫폼 개발자로서 영 찝찝한 점이다. 안드로이드 버전별로 대응해왔던 것에서 이제는 국내용, 해외용도 모두 다 봐야 한다는 뜻이니까. 칩제조사와 플랫폼 벤더가 통합된 iOS 개발자들이 부러워지는 순간이다.

 

더 난감한 점은 게다가 이쪽 부분은 제조사에서 코드를 숨겨놔 에러가 발생해도 코드도 볼 수 없다는 사실이다... Logcat 메시지에서도 에러가 발생하면 알려주는 정보가 0x 로 시작하는 16진수의 플래그값 외에 알려주는게 더 없다. 스택오버플로우에라도 의지해볼 수 있다면 좋으련만 이상하게도 MediaCodec 관련 정보는 별로 없다. MediaCodec이 2012년도에 등장했는데도 아직까지 이렇게 정보가 많지 않다는 것을 보면 다들 MediaCodec으로 개발한 정보를 숨겨놓는건지 아니면 쓰려다가 지레 포기하고 외부 라이브러리를 사용한 것인지. MediaCodec을 이용한 오픈소스 프로젝트가 몇몇 있기는 한데 코드에서 정작 중요한 정보들은 byte code로 꽁꽁 숨겨놨다.(이럴거면 왜 공개했다고 한건지) 인터넷 상에서 정보를 찾기는 어렵고 개발하는데 난감하지만 대안이 없어 어쩔 수 없이 사용해야하는 라이브러리다.

 

 

2. MediaCodec - 개발 참고 자료

 

 

개발하기 어렵지만 그래도 참고할 만한 자료가 전혀 없는 것은 아니다. 단, 다른 라이브러리들처럼 친절한 문서 페이지는 기대하지 않는게 좋다.

 

 

2.1 CTS 코드

 

구글에서는 CTS (호환성 테스트) 검증에 사용한 코드를 공개하고 있다. 이 테스트 코드는 모든 제조사들이 출시하기 전에 PASS 해야하기 때문에 여기 코드들은 칩 디펜던시가 없이 모두 안정적으로 동작한다고 봐도 될 것 같다. https://bigflake.com/mediacodec/ 라는 사이트에서 MediaCodec과 관련된 CTS 테스트코드 주소와 테스트 목적에 대해서 짤막하게 소개해주고 있으니 여기서 구현하려는 것과 가장 가까운 테스트 코드를 참고하자. 테스트 코드를 보면 알겠지만 구글에서도 테스트 코드는 거지같이짜서  한눈에 보기가 쉽진 않다.

 

2.2 grafika 

 

구글에서 MediaCodec관련 문의가 하도 많이 들어와 만든 것인지 모르겠으나 MediaCodec 개발자로서는 한여름의 에어컨과도 같은 오픈소스다. https://github.com/google/grafika 여기에는 MediaCodec 라이브러리를 이용해 응용할 수 있는 무비 플레이어, 카메리 영상 처리, 비디오 트랜스코딩과 같은 다양한 예제를 담고 있다. README 페이지에 이 코드는 구글 공식 프로덕트가 아니고(그럼 구글 저장소에는 왜 있는건지?) 테스트를 제대로 하지 않아 안정적이지 않다고도 크게 써놔서 이 코드들이 모든 디바이스에서 동작할지는 확신 할 수 없지만, MediaCodec을 기본적으로 어떻게 써야할지 감을 익힐때 사용하면 유용하다. 

 

2013, 2014년도에 주로 작성되고 그 이후에는 최신 안드로이드버전 호환만 관리했기 때문에 모든 코드가 JAVA로 되어 있어 Kotlin으로 옮길 때 린트가 많이 생기는 단점이 있다.

 

728x90

'개발 > 안드로이드' 카테고리의 다른 글

MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15