[Android] ContentProvider 구현 및 사용법

WonseokOh·2022년 5월 6일
0

Android

목록 보기
8/16
post-thumbnail

ContentProvider란?

  ContentProivider는 Activity, BroadcastReceiver, Service와 동일하게 안드로이드 애플리케이션을 구성하는 4대 구성요소 중 하나로 다른 애플리케이션의 데이터에 접근이 필요할 때 사용하게 되는 컴포넌트입니다. 일반적으로 각 앱은 하나의 프로세스로 실행되며 자신의 프로세스에서 사용하는 데이터는 자신만 접근가능하도록 되어 있습니다. 하지만 사진첩에 있는 사진들을 가져오거나 연락처에 있는 연락처 정보들을 가져와야할 경우가 있습니다. 이 때 사진 앱에는 ContentProvider가 구현되어 있어 데이터를 다른 앱에서 사용할 수 있도록 통로를 제공해줍니다. 그리고 ContentProvider는 앱의 보안을 위해 생겨난 안드로이드 기본 구성요소이기 때문에 안드로이드 시스템에서 관리하며 Manifest 파일에 명시해줘야 시스템에서 알 수 있습니다.

일반적으로 다음과 같이 두 가지 경우에서 주로 ContentProvider를 사용하게 됩니다.

  • 내 애플리케이션에서 다른 애플리케이션의 ContentProvider에 액세스 하기 위해 코드 구현
  • 내 애플리케이션에 ContentProvider를 생성하여 다른 애플리케이션과 데이터 공유

  ContentProvider가 생성된 다른 애플리케이션의 데이터를 접근하기 위해서는 ContentResolver 객체를 사용하여 ContentProvider와 서버-클라이언트 구조로 통신을 주고 받아야 합니다. 즉, ContentResolver 객체가 ContentProvider에 데이터를 요청하게 되고 ContentProvider는 요청된 작업을 실행하고 결과를 반환하는 구조입니다.

  ContentProvider에서 공유할 수 있는 데이터는 데이터베이스, 파일, SharedPreference 3가지가 있습니다. 하지만 일반적으로는 ContentProvider는 CRUD 동작을 기본으로 하고 있기에 데이터베이스가 주로 사용됩니다. 위의 그림에서는 Activity나 Fragment에서 CursorLoader를 호출하고 ContentResolver를 사용하여 ContentProvider와 통신을 하지만 백그라운드에서 굳이 쿼리를 하지 않기에 CursorLoader는 사용하지 않고 실습코드를 작성하였습니다. 원래는 두 가지의 앱을 만들어서 하나의 앱에서 ContentProvider를 생성하고 다른 앱에서 데이터를 가져오도록 해야하지만 구현상의 편리함을 위해 하나의 앱에서 테스트 하도록 구현하였습니다.


데이터베이스 구현

  안드로이드에서 스키마를 잘 작성하기 위해서 Contract 클래스를 생성하고 BaseColumns 인터페이스를 구현하여 기본키 필드를 상속하도록 가이드하고 있습니다.

object PersonContract {
    object PersonEntry : BaseColumns{
        const val TABLE_NAME = "person"
        const val PERSON_NAME = "name"
        const val PERSON_AGE = "age"
        const val PERSON_MOBILE = "mobile"
    }
}

PersonContract 객체에서 PersonEntry를 정의하여 해당 테이블에 필요한 테이블 명, 컬럼 명을 정의하고 있습니다.

class DatabaseHelper private constructor(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

    override fun onCreate(db: SQLiteDatabase?) {
        db?.execSQL(CREATE_TABLE)
    }

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
        TODO("Not yet implemented")
    }

    companion object{
        const val DATABASE_NAME = "person.db"
        const val DATABASE_VERSION = 1
        var instance : DatabaseHelper? = null

        val ALL_COLUMNS = arrayOf(BaseColumns._ID, PERSON_NAME, PERSON_AGE, PERSON_MOBILE)
        val CREATE_TABLE =
            "CREATE TABLE $TABLE_NAME (${BaseColumns._ID} INTEGER PRIMARY KEY AUTOINCREMENT, " +
                    "$PERSON_NAME TEXT, $PERSON_AGE INTEGER, $PERSON_MOBILE TEXT)"

        fun getInstance(context: Context): DatabaseHelper =
            if(instance == null) DatabaseHelper(context.applicationContext) else instance!!
    }
}

DatabaseHelper는 SQLiteOpenHelper 클래스를 상속받고 있으며, person.db 파일에 데이터를 저장하고 있습니다. DatabaseHelper 인스턴스가 생성될 때 onCreate 콜백이 호출되고 PersonContract 에서 정의한 person 테이블을 생성합니다. SQLiteOpenHelper 클래스는 DB 생성과 버전관리를 도와주는 헬퍼 클래스로 자세하게 알고 싶으면 링크를 참고하시면 됩니다.


ContentProvider 클래스 구현

  PersonProvider라는 이름의 클래스를 구현하고 ContentProvider 클래스를 상속하도록 합니다.

class PersonProvider : ContentProvider() {
    private lateinit var database : SQLiteDatabase

    override fun onCreate(): Boolean {
        if(context == null) return false
        database = DatabaseHelper.getInstance(context!!).writableDatabase

        return true
    }

    override fun query(
        uri: Uri,
        projcetion: Array<out String>?,
        selection: String?,
        selctionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        var cursor: Cursor? = null
        when(uriMatcher.match(uri)){
            PERSONS -> {
                cursor = database.query(TABLE_NAME, projcetion, selection, selctionArgs, null, null, sortOrder)
            }
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }

        cursor.setNotificationUri(context?.contentResolver, uri)
        return cursor
    }

    override fun getType(uri: Uri): String? {
        when(uriMatcher.match(uri)){
            PERSONS -> return "vnd.android.cursor.dir/persons"
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        val id = database.insert(TABLE_NAME, null, values)

        if(id > 0){
            val uri = ContentUris.withAppendedId(CONTENT_URI, id)
            context?.contentResolver?.notifyChange(uri, null)
            return uri
        }

        throw SQLException("추가 실패 URI : $uri")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        var count = 0
        when(uriMatcher.match(uri)){
            PERSONS -> count = database.delete(TABLE_NAME, selection, selectionArgs)
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
        context?.contentResolver?.notifyChange(uri, null)
        return count
    }

    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
        var count = 0
        when(uriMatcher.match(uri)){
            PERSONS -> count = database.update(TABLE_NAME, values, selection, selectionArgs)
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
        context?.contentResolver?.notifyChange(uri, null)
        return count
    }

    companion object{
        const val AUTHORITY = "com.example.contentprovider"
        const val BASE_PATH = "person"
        val CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + BASE_PATH)

        const val PERSONS = 1
        const val PERSON_ID = 2

        val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
            addURI(AUTHORITY, BASE_PATH, PERSONS)
            addURI(AUTHORITY, BASE_PATH + "/#", PERSON_ID)
        }
    }
}

추상 클래스인 ContentProvider는 onCreate, query, getType, insert, delete, update 6가지 메소드를 구현해야 합니다. onCreate 메소드를 제외하고는 ContentResolver에 동일한 메소드명이 정의되어 있으며 해당 메소드를 통해 ContentProvider와 통신하여 데이터를 가져올 수 있습니다. 전체 코드를 한 번에 보면 어려우니 쪼개서 하나씩 살펴봅시다.


Content URI 설계

    companion object{
        const val AUTHORITY = "com.example.contentprovider"
        const val BASE_PATH = "person"
        val CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + BASE_PATH)

        const val PERSONS = 1
        const val PERSON_ID = 2

        val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
            addURI(AUTHORITY, BASE_PATH, PERSONS)
            addURI(AUTHORITY, BASE_PATH + "/#", PERSON_ID)
        }
    }

  Content Provider를 만들기 위해서는 고유한 값을 가진 Content URI를 만들어야 합니다. Content URI는 Provider에서 데이터를 식별하는 URI로 Provider의 권한과 테이블 또는 파일을 가리키는 이름이 포함됩니다. 또한 ID 부분은 테이블 내 개별적인 행을 가리키게 됩니다. 이 Content URI가 ContentProvider의 모든 메소드에 필수 인자로 들어가고 이를 통해 액세스할 테이블, 행 또는 파일을 결정할 수 있습니다.

content://com.example.contentprovider/person/1

  • content://

    ContentProvider에 제어되는 데이터라는 의미로 항상 content://로 시작됨

  • Authority

    com.example.contentprovider를 가리키며, ContentProvider를 구분하는 고유의 값으로 사용됨

  • Base Path

    테이블 또는 파일을 가리키는 이름으로 해당 URI에서는 person 테이블을 가리킴

  • ID

    마지막 숫자로 테이블 내 행(레코드)을 가리킴

Content URI 형식은 다음과 같으며 위의 예시에서는 Authority는 패키지명으로 지정하였고 Base Path는 PersonContract에 정의한 테이블 명과 동일하게 지정하였습니다. 그 외에 UriMatcher 객체가 생성되었고 addURI 메소드를 통해 URI를 추가합니다. UriMatcher 객체는 URI 매칭하는데 사용되는 클래스로 addURI 메소드로 특정 URI이 등록되었는지 확인할 수 있습니다. addURI 메소드의 인자로 Authority, Base Path, Code를 필요로 하며 match 메소드를 통해 매칭된 Authority와 Base Path로 구성된 구성된 URI가 일치한다면 Code를 반환하고 일치하지 않으면 -1을 반환합니다.


query 메소드 구현

    override fun query(
        uri: Uri,
        projcetion: Array<out String>?,
        selection: String?,
        selctionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        var cursor: Cursor? = null
        when(uriMatcher.match(uri)){
            PERSONS -> {
                cursor = database.query(TABLE_NAME, projcetion, selection, selctionArgs, null, null, sortOrder)
            }
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }

        cursor.setNotificationUri(context?.contentResolver, uri)
        return cursor
    }

  query 메소드는 데이터를 조회할 때 사용합니다. 이전에 uriMatcher에 등록된 URI와 일치한 URI이라면 SQLiteDatabase query 메소드를 호출하게 되고 일치하지 않으면 IllegalArgumentException을 반환하도록 하였습니다. cursor.setNotificationUri 메소드는 반환하기 전에 호출하게 되는데 만약 비동기로 쿼리 중 삽입이 될 경우에 업데이트 되는 정보는 알아서 알 수 있도록 설정하는 메소드입니다. query 메소드의 구현에 대해 잘 알고 싶다면 공식문서를 살펴봐도 좋습니다.


insert 메소드 구현

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        val id = database.insert(TABLE_NAME, null, values)

        if(id > 0){
            val uri = ContentUris.withAppendedId(CONTENT_URI, id)
            context?.contentResolver?.notifyChange(uri, null)
            return uri
        }

        throw SQLException("추가 실패 URI : $uri")
    }

  insert는 데이터를 추가할 때 사용하는 메소드로 매개변수인 ContentValues 객체에는 저장할 컬럼명과 값들이 들어갑니다. SQLiteDatabase의 insert 메소드를 호출하여 삽입될 row ID를 반환 받은 후 ContentUris의 withAppendedId 함수를 통해 Content URI에 row ID가 합쳐진 URI를 반환받을 수 있습니다. 여기서 notifyChange 메소드는 데이터가 추가, 수정, 삭제 되었을 때 변경이 일어났음을 알려주는 역할을 하게 됩니다. 일반적으로 모든 쿼리는 비동기로 요청을 하기에 notifyChange 함수를 호출해야 올바른 결과를 받을 수 있습니다.


delete 메소드 구현

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        var count = 0
        when(uriMatcher.match(uri)){
            PERSONS -> count = database.delete(TABLE_NAME, selection, selectionArgs)
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
        context?.contentResolver?.notifyChange(uri, null)
        return count
    }

  delete 메소드는 where절에 해당하는 selection 매개변수와 where절 내의 ?에 해당되는 selectionArgs를 매개변수로 받아 데이터를 삭제하는 역할을 합니다. uriMatcher로 URI 일치하는지 확인한 후 SQLiteDatabase의 delete 메소드를 수행합니다. 동일하게 변경사항을 알리기 위해 notifyChange를 호출합니다.


update 메소드 구현

    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
        var count = 0
        when(uriMatcher.match(uri)){
            PERSONS -> count = database.update(TABLE_NAME, values, selection, selectionArgs)
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
        context?.contentResolver?.notifyChange(uri, null)
        return count
    }

  update 메소드도 큰 차이가 없어서 설명은 생략합니다.


getType 메소드 구현

    override fun getType(uri: Uri): String? {
        when(uriMatcher.match(uri)){
            PERSONS -> return "vnd.android.cursor.dir/persons"
            else -> throw IllegalArgumentException("알 수 없는 URI : $uri")
        }
    }

  getType 메소드는 Content Uri이 반환하는 데이터 유형(MIME)을 알고 싶을 때 사용하는 메소드입니다. 텍스트, HTML 또는 JPEG과 같은 보편적인 유형의 데이터라면 getType이 해당 데이터에 대한 표준 MIME 유형을 반환해야 하고 데이터 테이블 또는 테이블의 행을 가리킨다면 Android 공급업체별 MIME 형식으로 반환해야 합니다. 반환되는 유형별 MIME 형식은 공식문서를 참고하시면 됩니다.


ContentProvider를 통해 데이터 액세스

  ContentProvider를 생성하여 내 앱의 데이터를 다른 앱에서 접근할 수 있도록 하였습니다. 이제 다른 앱에서 데이터를 액세스하는 실습 코드를 작성해보려고 합니다. 이전에 설명하였듯이 ContentProvider가 생성된 앱의 데이터를 사용하기 위해서는 클라이언트 역할하는 ContentResolver를 사용해야 합니다.


데이터 추가

    private fun insertData() {
        println("insertData가 호출됨")
        var uri = Uri.parse("content://com.example.contentprovider/person")
        val values = ContentValues().apply {
            put(PERSON_NAME, "ows")
            put(PERSON_AGE, 28)
            put(PERSON_MOBILE, "010-0000-0000")
        }

        uri = contentResolver.insert(uri, values)
        println("insertDatat 결과 : $uri")
    }

  ContentProvider에서 정의한 Content Uri를 선언한 뒤 ContentResolver의 insert 메소드에 Uri와 추가할 데이터들을 가지고 있는 ContentValues를 대입하면 됩니다. 위의 코드에서는 name은 ows, age는 28, mobile은 010-0000-0000으로 설정 후 추가하였습니다.


데이터 조회

    @SuppressLint("Range")
    private fun queryData() {
        val uri = Uri.parse("content://com.example.contentprovider/person")
        val columns = arrayOf(PERSON_NAME, PERSON_AGE, PERSON_MOBILE)
        val cursor = contentResolver.query(uri, columns, null, null,"name ASC")
        println("queryData 결과 ${cursor?.count}")

        cursor?.let { cursor ->
            var index = 0
            while(cursor.moveToNext()){
                val name = cursor.getString(cursor.getColumnIndex(columns.get(0)))
                val age = cursor.getInt(cursor.getColumnIndex(columns.get(1)))
                val mobile = cursor.getString(cursor.getColumnIndex(columns.get(2)))

                println("#${index} -> ${name}, ${age}, ${mobile}")
                index++
            }
        }
    }

  다른 앱 데이터를 쿼리하기 위해서는 조회하고 싶은 컬럼 명과 where 절에 필요한 변수들, 오름차순에 대한 문자열을 ContentResolver의 query 메소드에 대입하면 됩니다. query 메소드의 결과로 Cursor 객체가 반환하는데 Cursor 객체는 쿼리의 결과를 읽고 쓸 수 있게 해주는 인터페이스 역할을 하게 됩니다. Cursor의 moveToNext 메소드로 데이터를 하나씩 확인할 수 있으며 컬럼명에 해당하는 인덱스를 알고 싶다면 getColumnIndex 메소드를 사용하시면 됩니다. 데이터에 들어간 모든 컬럼명들을 확인하고 싶을 때는 getColumnNames 메소드를 이용하시면 됩니다.


데이터 업데이트 및 삭제

    private fun updateDate() {
        val uri = Uri.parse("content://com.example.contentprovider/person")
        val selection = "mobile = ?"
        val selectionArgs = arrayOf("010-0000-0000")

        val values = ContentValues().apply {
            put("mobile","010-1000-1000")
        }

        val count = contentResolver.update(uri, values, selection, selectionArgs)
        println("updateData 결과 ${count}")
    }
    
    private fun deleteData() {
        val uri = Uri.parse("content://com.example.contentprovider/person")
        val selection = "name = ?"
        val selectionArgs = arrayOf("ows")

        val count = contentResolver.delete(uri, selection, selectionArgs)
        println("deleteData 결과 ${count}")
    }

    private fun println(str: String) = with(binding){
        resultTextView.append("$str\n")
    }

  update, delete 명령도 크게 차이나지 않고 ContentProvider에서 정의한 Content Uri은 필수적으로 필요로 하며 그 외 where절에 필요한 매개변수가 들어갑니다. update 메소드에서 mobile 정보가 010-0000-0000으로 해당되는 데이터들을 모두 010-1000-1000으로 변경하도록 구현하였고 delete 메소드에서는 name 정보가 ows로 지정된 데이터를 모두 삭제하도록 구현하였습니다.


ContentProvider Manifest 등록

  위의 코드만 작성한 후 실행을 하게 되면 런타임 에러가 발생하게 됩니다. ContentProvider는 안드로이드 애플리케이션 구성요소로 시스템에 등록하는 과정이 필요합니다. Manifest에 ContentProvider를 명시하게 되면 안드로이드 시스템에서 해당 제공자의 정보를 알 수 있게 됩니다.

        <provider
            android:authorities="com.example.contentprovider"
            android:name=".PersonProvider"
            android:exported="true"
            android:readPermission="com.example.contentprovider.READ_DATABASE"
            android:writePermission="com.example.contentprovider.WRITE_DATABASE"/>

application 태그 안에 provider 태그를 추가하고 authorities에 ContentProvider를 정의할 때 사용한 Authority 정보와 동일하게 입력합니다. name속성 값은 ContentProvider를 상속한 클래스 명을 넣어줍니다. 다른 애플리케이션에서 해당 ContentProvider를 사용하기 위해서는 exported 값을 true로 해야 합니다. 이후 클라이언트 애플리케이션에서 ContentProvider에 쿼리를 하고 변경하기 위한 권한인 readePermission, writePermission을 정의해야 합니다. 자세한 정보는 아래 공식문서를 확인하시면 됩니다.


    <permission android:name="com.android.contentprovider.READ_DATABASE" android:protectionLevel="normal"/>
    <permission android:name="com.android.contentprovider.WRITE_DATABASE" android:protectionLevel="normal"/>

ContentProvider를 사용하려는 애플리케이션에서는 다음과 같이 permission 태그를 통해 권한을 지정해야 합니다. permission 태그는 권한을 새로 정의할 때 사용되는 태그로 Provider의 readPemission, writePermission에 정의된 권한을 모두 작성해야 합니다.


정리

  Do It 안드로이드 앱 프로그래밍 책에서 ContentProvider를 사용하는 예제를 가지고 kotlin으로 바꾸어 실습을 해보았습니다. ContentProvider는 다른 앱과 데이터를 공유하기 위한 인터페이스로 ContentResolver를 통해서 ContentProvider와 통신할 수 있습니다.


참고

profile
"Effort never betrays"

0개의 댓글