TV 앱의 검색 기능 향상

Android TV는 Android 검색 인터페이스를 사용하여 설치된 앱에서 콘텐츠 데이터를 가져와서 사용자에게 검색결과를 전달합니다. 이 결과에 앱의 콘텐츠 데이터를 포함하여 사용자가 앱의 콘텐츠에 즉시 액세스하도록 할 수 있습니다.

앱에서 사용자가 검색 대화상자에 문자를 입력할 때 추천 검색결과를 생성하는 데이터 필드를 Android TV에 제공해야 합니다. 이렇게 하려면 앱에서 콘텐츠 제공자 및 기타 중요한 Android TV 정보를 설명하는 searchable.xml 구성 파일과 함께 추천을 제공하는 콘텐츠 제공자를 구현해야 합니다. 또한 사용자가 추천 검색결과를 선택할 때 실행되는 인텐트를 처리하는 활동이 필요합니다. 이 내용은 모두 맞춤 추천 추가에 자세히 설명되어 있습니다. 여기서는 Android TV 앱의 요점을 설명합니다.

이 과정에서는 Android에서 검색 기능을 사용하는 방법에 관한 지식을 바탕으로 Android TV에서 검색 가능한 앱을 만드는 방법을 보여줍니다. 이 과정을 진행하기 전에 Search API 가이드에 설명된 개념을 숙지해야 합니다. 검색 기능 추가 교육도 참조하세요.

이번 논의에서는 Android TV GitHub 저장소에 있는 Android Leanback 샘플 앱의 일부 코드를 설명합니다.

열 식별

SearchManager는 예상되는 데이터를 로컬 데이터베이스의 열로 표시합니다. 데이터의 형식과 관계없이 일반적으로 콘텐츠 데이터에 액세스하는 클래스에서 데이터 필드를 이러한 열로 매핑해야 합니다. 기존 데이터를 필수 필드로 매핑하는 클래스를 만드는 방법에 관한 자세한 내용은 추천 테이블 만들기를 참조하세요.

SearchManager 클래스에는 Android TV를 위한 여러 열이 포함됩니다. 중요한 열 중 일부가 아래에 설명되어 있습니다.

설명
SUGGEST_COLUMN_TEXT_1 콘텐츠의 이름(필수)
SUGGEST_COLUMN_TEXT_2 콘텐츠의 텍스트 설명
SUGGEST_COLUMN_RESULT_CARD_IMAGE 콘텐츠의 이미지/포스터/표지
SUGGEST_COLUMN_CONTENT_TYPE 미디어의 MIME 유형
SUGGEST_COLUMN_VIDEO_WIDTH 미디어의 해상도 너비
SUGGEST_COLUMN_VIDEO_HEIGHT 미디어의 해상도 높이
SUGGEST_COLUMN_PRODUCTION_YEAR 콘텐츠 제작 연도(필수)
SUGGEST_COLUMN_DURATION 미디어의 기간(밀리초)(필수)

검색 프레임워크에는 다음 열이 필요합니다.

콘텐츠의 이 열 값이 Google 서버에서 찾은 다른 공급자의 동일한 콘텐츠 값과 일치하는 경우 시스템이 콘텐츠의 세부정보 뷰에서 딥 링크를 다른 제공자의 앱으로 연결되는 링크와 함께 앱에 제공합니다. 이 내용은 아래 세부정보 화면에 표시되는 앱의 딥 링크에 자세히 설명되어 있습니다.

애플리케이션의 데이터베이스 클래스에 열이 다음과 같이 정의될 수 있습니다.

Kotlin

    class VideoDatabase {
        companion object {
            // The columns we'll include in the video database table
            val KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1
            val KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2
            val KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE
            val KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE
            val KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE
            val KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH
            val KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT
            val KEY_AUDIO_CHANNEL_CONFIG = SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG
            val KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE
            val KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE
            val KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE
            val KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE
            val KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR
            val KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION
            val KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION
            ...
        }
        ...
    }
    

자바

    public class VideoDatabase {
        //The columns we'll include in the video database table
        public static final String KEY_NAME = SearchManager.SUGGEST_COLUMN_TEXT_1;
        public static final String KEY_DESCRIPTION = SearchManager.SUGGEST_COLUMN_TEXT_2;
        public static final String KEY_ICON = SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE;
        public static final String KEY_DATA_TYPE = SearchManager.SUGGEST_COLUMN_CONTENT_TYPE;
        public static final String KEY_IS_LIVE = SearchManager.SUGGEST_COLUMN_IS_LIVE;
        public static final String KEY_VIDEO_WIDTH = SearchManager.SUGGEST_COLUMN_VIDEO_WIDTH;
        public static final String KEY_VIDEO_HEIGHT = SearchManager.SUGGEST_COLUMN_VIDEO_HEIGHT;
        public static final String KEY_AUDIO_CHANNEL_CONFIG =
                SearchManager.SUGGEST_COLUMN_AUDIO_CHANNEL_CONFIG;
        public static final String KEY_PURCHASE_PRICE = SearchManager.SUGGEST_COLUMN_PURCHASE_PRICE;
        public static final String KEY_RENTAL_PRICE = SearchManager.SUGGEST_COLUMN_RENTAL_PRICE;
        public static final String KEY_RATING_STYLE = SearchManager.SUGGEST_COLUMN_RATING_STYLE;
        public static final String KEY_RATING_SCORE = SearchManager.SUGGEST_COLUMN_RATING_SCORE;
        public static final String KEY_PRODUCTION_YEAR = SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR;
        public static final String KEY_COLUMN_DURATION = SearchManager.SUGGEST_COLUMN_DURATION;
        public static final String KEY_ACTION = SearchManager.SUGGEST_COLUMN_INTENT_ACTION;
    ...
    

또한 SearchManager 열과 데이터 필드 사이에 매핑을 작성하는 경우 _ID를 지정하여 각 행에 고유 ID를 부여해야 합니다.

Kotlin


    companion object {
        ....

        private fun buildColumnMap(): Map<String, String> {
            return mapOf(
              KEY_NAME to KEY_NAME,
              KEY_DESCRIPTION to KEY_DESCRIPTION,
              KEY_ICON to KEY_ICON,
              KEY_DATA_TYPE to KEY_DATA_TYPE,
              KEY_IS_LIVE to KEY_IS_LIVE,
              KEY_VIDEO_WIDTH to KEY_VIDEO_WIDTH,
              KEY_VIDEO_HEIGHT to KEY_VIDEO_HEIGHT,
              KEY_AUDIO_CHANNEL_CONFIG to KEY_AUDIO_CHANNEL_CONFIG,
              KEY_PURCHASE_PRICE to KEY_PURCHASE_PRICE,
              KEY_RENTAL_PRICE to KEY_RENTAL_PRICE,
              KEY_RATING_STYLE to KEY_RATING_STYLE,
              KEY_RATING_SCORE to KEY_RATING_SCORE,
              KEY_PRODUCTION_YEAR to KEY_PRODUCTION_YEAR,
              KEY_COLUMN_DURATION to KEY_COLUMN_DURATION,
              KEY_ACTION to KEY_ACTION,
              BaseColumns._ID to ("rowid AS " + BaseColumns._ID),
              SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID),
              SearchManager.SUGGEST_COLUMN_SHORTCUT_ID to ("rowid AS " + SearchManager.SUGGEST_COLUMN_SHORTCUT_ID)
            )
        }
    }
    

자바

    ...
      private static HashMap<String, String> buildColumnMap() {
        HashMap<String, String> map = new HashMap<String, String>();
        map.put(KEY_NAME, KEY_NAME);
        map.put(KEY_DESCRIPTION, KEY_DESCRIPTION);
        map.put(KEY_ICON, KEY_ICON);
        map.put(KEY_DATA_TYPE, KEY_DATA_TYPE);
        map.put(KEY_IS_LIVE, KEY_IS_LIVE);
        map.put(KEY_VIDEO_WIDTH, KEY_VIDEO_WIDTH);
        map.put(KEY_VIDEO_HEIGHT, KEY_VIDEO_HEIGHT);
        map.put(KEY_AUDIO_CHANNEL_CONFIG, KEY_AUDIO_CHANNEL_CONFIG);
        map.put(KEY_PURCHASE_PRICE, KEY_PURCHASE_PRICE);
        map.put(KEY_RENTAL_PRICE, KEY_RENTAL_PRICE);
        map.put(KEY_RATING_STYLE, KEY_RATING_STYLE);
        map.put(KEY_RATING_SCORE, KEY_RATING_SCORE);
        map.put(KEY_PRODUCTION_YEAR, KEY_PRODUCTION_YEAR);
        map.put(KEY_COLUMN_DURATION, KEY_COLUMN_DURATION);
        map.put(KEY_ACTION, KEY_ACTION);
        map.put(BaseColumns._ID, "rowid AS " +
                BaseColumns._ID);
        map.put(SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID, "rowid AS " +
                SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID);
        map.put(SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, "rowid AS " +
                SearchManager.SUGGEST_COLUMN_SHORTCUT_ID);
        return map;
      }
    ...
    

위의 예에서 SUGGEST_COLUMN_INTENT_DATA_ID 필드의 매핑을 확인하세요. URI의 이 부분이 이 행의 데이터에 고유한 콘텐츠를 가리킵니다. 즉 콘텐츠가 저장된 위치를 설명하는 URI의 마지막 부분입니다. URI의 첫 번째 부분은 테이블의 모든 행에 공통된 경우 아래 추천 검색어 처리에 설명된 대로 searchable.xml 파일에 android:searchSuggestIntentData 속성으로 설정됩니다.

URI의 첫 번째 부분이 테이블의 행마다 다른 경우 이 값을 SUGGEST_COLUMN_INTENT_DATA 필드와 매핑합니다. 사용자가 이 콘텐츠를 선택하는 경우 실행되는 인텐트가 SUGGEST_COLUMN_INTENT_DATA_IDandroid:searchSuggestIntentData 속성 또는 SUGGEST_COLUMN_INTENT_DATA 필드 값의 조합에서 인텐트 데이터를 제공합니다.

추천 검색어 데이터 제공

Android TV 추천 대화상자에 검색어 추천을 반환하려면 콘텐츠 제공자를 구현하세요. 시스템은 문자가 입력될 때마다 query() 메서드를 호출하여 콘텐츠 제공자에게 추천 검색어가 있는지 문의합니다. query() 구현에서 콘텐츠 제공자는 추천 데이터를 검색하고 추천을 위해 지정한 행을 가리키는 Cursor를 반환합니다.

Kotlin

    fun query(uri: Uri, projection: Array<String>, selection: String, selectionArgs: Array<String>,
            sortOrder: String): Cursor {
        // Use the UriMatcher to see what kind of query we have and format the db query accordingly
        when (URI_MATCHER.match(uri)) {
            SEARCH_SUGGEST -> {
                Log.d(TAG, "search suggest: ${selectionArgs[0]} URI: $uri")
                if (selectionArgs == null) {
                    throw IllegalArgumentException(
                            "selectionArgs must be provided for the Uri: $uri")
                }
                return getSuggestions(selectionArgs[0])
            }
            else -> throw IllegalArgumentException("Unknown Uri: $uri")
        }
    }

    private fun getSuggestions(query: String): Cursor {
        val columns = arrayOf<String>(
                BaseColumns._ID,
                VideoDatabase.KEY_NAME,
                VideoDatabase.KEY_DESCRIPTION,
                VideoDatabase.KEY_ICON,
                VideoDatabase.KEY_DATA_TYPE,
                VideoDatabase.KEY_IS_LIVE,
                VideoDatabase.KEY_VIDEO_WIDTH,
                VideoDatabase.KEY_VIDEO_HEIGHT,
                VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
                VideoDatabase.KEY_PURCHASE_PRICE,
                VideoDatabase.KEY_RENTAL_PRICE,
                VideoDatabase.KEY_RATING_STYLE,
                VideoDatabase.KEY_RATING_SCORE,
                VideoDatabase.KEY_PRODUCTION_YEAR,
                VideoDatabase.KEY_COLUMN_DURATION,
                VideoDatabase.KEY_ACTION,
                SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
        )
        return videoDatabase.getWordMatch(query.toLowerCase(), columns)
    }
    

자바

    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        // Use the UriMatcher to see what kind of query we have and format the db query accordingly
        switch (URI_MATCHER.match(uri)) {
            case SEARCH_SUGGEST:
                Log.d(TAG, "search suggest: " + selectionArgs[0] + " URI: " + uri);
                if (selectionArgs == null) {
                    throw new IllegalArgumentException(
                            "selectionArgs must be provided for the Uri: " + uri);
                }
                return getSuggestions(selectionArgs[0]);
            default:
                throw new IllegalArgumentException("Unknown Uri: " + uri);
        }
    }

    private Cursor getSuggestions(String query) {
        query = query.toLowerCase();
        String[] columns = new String[]{
            BaseColumns._ID,
            VideoDatabase.KEY_NAME,
            VideoDatabase.KEY_DESCRIPTION,
            VideoDatabase.KEY_ICON,
            VideoDatabase.KEY_DATA_TYPE,
            VideoDatabase.KEY_IS_LIVE,
            VideoDatabase.KEY_VIDEO_WIDTH,
            VideoDatabase.KEY_VIDEO_HEIGHT,
            VideoDatabase.KEY_AUDIO_CHANNEL_CONFIG,
            VideoDatabase.KEY_PURCHASE_PRICE,
            VideoDatabase.KEY_RENTAL_PRICE,
            VideoDatabase.KEY_RATING_STYLE,
            VideoDatabase.KEY_RATING_SCORE,
            VideoDatabase.KEY_PRODUCTION_YEAR,
            VideoDatabase.KEY_COLUMN_DURATION,
            VideoDatabase.KEY_ACTION,
            SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID
        };
        return videoDatabase.getWordMatch(query, columns);
    }
    ...
    

manifest 파일에서 콘텐츠 제공자는 특별히 처리됩니다. 활동으로 태그되는 대신 <provider>로 설명됩니다. 제공자는 android:authorities 속성을 포함하여 콘텐츠 제공자의 네임스페이스를 시스템에 알려줍니다. 또한 Android 글로벌 검색에서 제공자가 반환한 결과를 사용할 수 있도록 android:exported 속성을 "true"로 설정해야 합니다.

    <provider android:name="com.example.android.tvleanback.VideoContentProvider"
        android:authorities="com.example.android.tvleanback"
        android:exported="true" />
    

추천 검색어 처리

추천 검색어 설정을 구성하려면 앱에 res/xml/searchable.xml 파일을 포함해야 합니다. 이 파일에는 콘텐츠 제공자의 네임스페이스를 시스템에 알려주기 위한 android:searchSuggestAuthority 속성이 포함됩니다. 이 값은 AndroidManifest.xml 파일에 있는 <provider> 요소의 android:authorities 속성에서 지정하는 문자열 값과 일치해야 합니다.

앱에는 애플리케이션의 이름인 라벨이 포함되어야 합니다. 시스템 검색 설정에서는 검색 가능한 앱을 열거할 때 이 라벨을 사용합니다.

searchable.xml 파일에는 또한 android:searchSuggestIntentAction이 맞춤 추천을 제공하기 위한 인텐트 작업을 정의하는 "android.intent.action.VIEW"와 함께 포함되어야 합니다. 이 작업은 아래에 설명된 검색어를 제공하는 인텐트 작업과 다릅니다. 추천을 위한 인텐트 작업을 선언하는 다른 방법은 인텐트 작업 선언을 참조하세요.

인텐트 작업과 함께 앱에서는 android:searchSuggestIntentData 속성을 사용하여 지정하는 인텐트 데이터를 제공해야 합니다. 이 부분이 콘텐츠를 가리키는 URI의 첫 번째 부분으로, 콘텐츠의 매핑 테이블 내 모든 행에 공통적인 URI 부분을 설명합니다. 각 행에 고유한 URI의 부분은 열 식별에 설명된 대로 SUGGEST_COLUMN_INTENT_DATA_ID 필드를 사용하여 설정됩니다. 추천을 위한 인텐트 데이터를 선언하는 다른 방법은 인텐트 데이터 선언을 참조하세요.

또한 android:searchSuggestSelection=" ?" 속성도 확인하세요. 이 속성은 query() 메서드의 selection 매개변수로 전달되는 값을 지정하며 여기서 물음표(?) 값은 쿼리 텍스트로 대체됩니다.

마지막으로 android:includeInGlobalSearch 속성을 "true" 값과 함께 포함해야 합니다. 다음은 searchable.xml 파일의 예입니다.

    <searchable xmlns:android="http://schemas.android.com/apk/res/android"
        android:label="@string/search_label"
        android:hint="@string/search_hint"
        android:searchSettingsDescription="@string/settings_description"
        android:searchSuggestAuthority="com.example.android.tvleanback"
        android:searchSuggestIntentAction="android.intent.action.VIEW"
        android:searchSuggestIntentData="content://com.example.android.tvleanback/video_database_leanback"
        android:searchSuggestSelection=" ?"
        android:searchSuggestThreshold="1"
        android:includeInGlobalSearch="true">
    </searchable>
    

검색어 처리

검색 대화상자에 (위 열 식별에 설명된) 앱의 열 중 하나의 값과 일치하는 단어가 있으면 시스템에서 즉시 ACTION_SEARCH 인텐트를 실행합니다. 인텐트를 처리하는 앱의 활동에서 값에 지정된 단어가 있는 열의 저장소를 검색하고 열이 있는 콘텐츠 항목의 목록을 반환합니다. AndroidManifest.xml 파일에서 다음과 같이 ACTION_SEARCH 인텐트를 처리하는 활동을 지정합니다.

    ...
      <activity
          android:name="com.example.android.tvleanback.DetailsActivity"
          android:exported="true">

          <!-- Receives the search request. -->
          <intent-filter>
              <action android:name="android.intent.action.SEARCH" />
              <!-- No category needed, because the Intent will specify this class component -->
          </intent-filter>

          <!-- Points to searchable meta data. -->
          <meta-data android:name="android.app.searchable"
              android:resource="@xml/searchable" />
      </activity>
    ...
      <!-- Provides search suggestions for keywords against video meta data. -->
      <provider android:name="com.example.android.tvleanback.VideoContentProvider"
          android:authorities="com.example.android.tvleanback"
          android:exported="true" />
    ...
    

활동은 또한 searchable.xml 파일을 참조하여 검색 가능한 구성을 설명해야 합니다. 글로벌 검색 대화상자를 사용하려면 manifest에서 검색어를 받아야 하는 활동을 설명해야 합니다. manifest에서는 또한 정확히 searchable.xml 파일에 설명된 대로 <provider> 요소를 설명해야 합니다.

세부정보 화면에 표시되는 앱의 딥 링크

추천 검색어 처리에 설명된 대로 검색 구성을 설정하고 열 식별에 설명된 대로 SUGGEST_COLUMN_TEXT_1, SUGGEST_COLUMN_PRODUCTION_YEARSUGGEST_COLUMN_DURATION 필드를 매핑한 경우 그림 1과 같이 콘텐츠의 시청 작업으로 연결되는 딥 링크가 사용자가 검색결과를 선택할 때 실행되는 세부정보 화면에 표시됩니다.

세부정보 화면에 표시되는 딥 링크

그림 1. 세부정보에 화면에 표시된 Videos by Google(Leanback) 샘플 앱의 딥 링크. Sintel: © copyright Blender Foundation, www.sintel.org.

사용자가 세부정보 화면에 '사용 가능' 버튼으로 표시된 앱의 링크를 선택하면 시스템에서 ACTION_VIEW(android:searchSuggestIntentAction으로 "android.intent.action.VIEW" 값과 함께 searchable.xml 파일에 설정된)룰 처리하는 활동을 시작합니다.

활동을 시작하기 위해 맞춤 인텐트를 설정할 수도 있으며 이 방법은 Android TV GitHub 저장소에 있는 Android Leanback 샘플 앱에서 확인할 수 있습니다. 샘플 앱은 자체 LeanbackDetailsFragment를 시작하여 선택된 미디어의 세부정보를 보여주지만, 사용자가 클릭을 한두 번 덜 하도록 즉시 미디어를 재생하는 활동을 시작해야 합니다.

검색 동작

Android TV의 홈 화면과 앱 내부에서 검색이 가능합니다. 이 두 경우의 검색결과는 서로 다릅니다.

홈 화면에서 검색

홈 화면에서 검색하는 경우 첫 번째 결과는 항목 카드에 표시됩니다. 콘텐츠를 재생할 수 있는 앱이 있으면 카드 하단에 각 앱의 링크가 표시됩니다.

TV 검색결과 재생

프로그래매틱 방식으로 앱을 항목 카드에 배치할 수 없습니다. 재생 옵션으로 포함하려면 앱의 검색결과가 콘텐츠의 제목, 연도 및 기간과 일치해야 합니다. 예를 들어 leanback-assistant 샘플 앱은 leanback-assistant가 영화 시청 옵션으로 포함된 'Big Buck Bunny'의 결과를 반환합니다.

카드 아래에 검색결과가 더 있을 수도 있습니다. 검색결과를 더 보려면 사용자가 리모컨을 누르고 아래로 스크롤해야 합니다. 각 앱의 결과는 별도의 행에 표시됩니다. 행의 순서를 제어할 수 없습니다. 시청 작업을 지원하는 앱이 먼저 나열됩니다.

TV 검색결과

앱에서 검색

사용자가 리모컨이나 게임 패드 컨트롤러에서 마이크를 시작하여 앱 내에서 검색을 시작할 수 있습니다. 검색결과는 앱의 콘텐츠 상단에 단일 행으로 표시됩니다. 앱에서는 자체 글로벌 검색 제공자를 사용하여 검색결과를 생성합니다.

TV 인앱 검색결과

자세히 알아보기

TV 앱을 검색하는 방법에 관한 자세한 내용은 검색 개요검색 기능 추가를 읽어보세요.

`SearchFragment`를 사용하여 인앱 검색 환경을 맞춤설정하는 방법에 관한 자세한 내용은 TV 앱 내 검색을 읽어보세요.

Android leanbackleanback-assistant 샘플 앱도 참조하세요.