검색 가능한 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
        ...
    }
    ...
}

Java

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)
        )
    }
}

Java

...
  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)
}

Java

@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);
}
...

매니페스트 파일에서 콘텐츠 제공업체는 특수하게 처리됩니다. 활동으로 태그되는 대신 <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.intent.action.VIEW" android:searchSuggestIntentAction도 포함해야 합니다. 이는 아래에 설명된 검색어를 제공하는 인텐트 작업과 다릅니다. 추천을 위한 인텐트 작업을 선언하는 다른 방법은 인텐트 작업 선언을 참고하세요.

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

또한 android:searchSuggestSelection=" ?" 속성은 물음표 (?) 값이 쿼리 텍스트로 대체되는 query() 메서드의 selection 매개변수로 전달된 값을 지정합니다.

마지막으로, 값이 "true" android:includeInGlobalSearch 속성도 포함해야 합니다. 다음은 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 파일을 참조하여 검색 가능한 구성을 설명해야 합니다. 글로벌 검색 대화상자를 사용하려면 매니페스트에서 검색어를 받아야 하는 활동을 설명해야 합니다. 또한 매니페스트는 searchable.xml 파일에 설명된 대로 <provider> 요소를 설명해야 합니다.

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

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

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

그림 1. 세부정보 화면에는 Google 동영상 (Leanback) 샘플 앱의 딥 링크가 표시됩니다. Sintel: © copyright Blender Foundation, www.sintel.org.

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

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

검색 동작

Android TV의 홈 화면과 앱 내에서 검색을 할 수 있습니다. 이 두 경우의 검색결과는 다릅니다.

홈 화면에서 검색

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

TV 검색결과 재생

프로그래매틱 방식으로 앱을 항목 카드에 배치할 수 없습니다. 재생 옵션으로 포함되려면 앱의 검색결과가 콘텐츠의 제목, 연도, 길이와 일치해야 합니다.

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

TV 검색결과

앱에서 검색

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

TV 인앱 검색결과

자세히 알아보기

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

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