Używanie popularnych wzorców Kotlin w Androidzie

Ten temat skupia się na najważniejszych aspektach języka Kotlin podczas programowania na Androida.

Praca z fragmentami

W kolejnych sekcjach posługujemy się przykładami Fragment, aby podkreślić niektóre z najlepszych funkcji aplikacji Kotlin.

Dziedziczenie

Zajęcia w Kotlin możesz zadeklarować za pomocą słowa kluczowego class. W tym przykładzie LoginFragment jest podklasą klasy Fragment. Możesz wskazać dziedziczenie, używając operatora : między podklasą a jej jednostką nadrzędną:

class LoginFragment : Fragment()

W tej deklaracji klasy LoginFragment odpowiada za wywołanie konstruktora swojej klasy nadrzędnej (Fragment).

W LoginFragment możesz zastąpić liczbę wywołań zwrotnych cyklu życia, aby reagować na zmiany stanu w Fragment. Aby zastąpić funkcję, użyj słowa kluczowego override, jak w tym przykładzie:

override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
): View? {
    return inflater.inflate(R.layout.login_fragment, container, false)
}

Aby odwołać się do funkcji w klasie nadrzędnej, użyj słowa kluczowego super, jak w tym przykładzie:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
}

Dopuszczalność wartości null i inicjowanie

W poprzednich przykładach niektóre parametry w zastępowanych metodach mają sufiksy ze znakiem zapytania ?. Oznacza to, że argumenty przekazywane dla tych parametrów mogą mieć wartość null. Pamiętaj, aby bezpiecznie przetwarzać ich wartości null.

Podczas deklarowania obiektu w Kotlin musisz zainicjować właściwości obiektu. Oznacza to, że po uzyskaniu instancji klasy możesz natychmiast odwołać się do dowolnej z jej dostępnych właściwości. Obiekty View w obiekcie Fragment nie są jednak gotowe do zawyżenia ich wartości do momentu wywołania Fragment#onCreateView, trzeba więc znaleźć sposób na odroczenie inicjowania właściwości View.

lateinit umożliwia opóźnienie inicjowania usługi. Jeśli korzystasz z elementu lateinit, musisz jak najszybciej zainicjować swoją właściwość.

Poniższy przykład pokazuje użycie lateinit do przypisania obiektów View w onViewCreated:

class LoginFragment : Fragment() {

    private lateinit var usernameEditText: EditText
    private lateinit var passwordEditText: EditText
    private lateinit var loginButton: Button
    private lateinit var statusTextView: TextView

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

        usernameEditText = view.findViewById(R.id.username_edit_text)
        passwordEditText = view.findViewById(R.id.password_edit_text)
        loginButton = view.findViewById(R.id.login_button)
        statusTextView = view.findViewById(R.id.status_text_view)
    }

    ...
}

Konwersja SAM

Aby nasłuchiwać zdarzeń kliknięć na Androidzie, zaimplementuj interfejs OnClickListener. Obiekty Button zawierają funkcję setOnClickListener(), która stosuje implementację OnClickListener.

OnClickListener ma jedną metodę abstrakcyjną (onClick()), którą musisz zaimplementować. setOnClickListener() zawsze przyjmuje OnClickListener jako argument, a OnClickListener ma zawsze tę samą pojedynczą metodę abstrakcyjną, więc tę implementację można przedstawić za pomocą funkcji anonimowej w Kotlin. Ten proces znany jest jako konwersja za pomocą pojedynczej metody abstrakcyjnej lub konwersja SAM.

Konwersja SAM może znacznie zwiększyć czytelność kodu. Ten przykład pokazuje, jak za pomocą konwersji SAM wdrożyć OnClickListener dla Button:

loginButton.setOnClickListener {
    val authSuccessful: Boolean = viewModel.authenticate(
            usernameEditText.text.toString(),
            passwordEditText.text.toString()
    )
    if (authSuccessful) {
        // Navigate to next screen
    } else {
        statusTextView.text = requireContext().getString(R.string.auth_failed)
    }
}

Kod w funkcji anonimowej przekazany do setOnClickListener() uruchamia się, gdy użytkownik kliknie loginButton.

Obiekty towarzyszące

Obiekty towarzyszące zapewniają mechanizm definiowania zmiennych lub funkcji, które są powiązane koncepcyjnie z danym typem, ale niepowiązane z konkretnym obiektem. Obiekty towarzyszące są podobne do używania na potrzeby zmiennych i metod słowa kluczowego static w języku Java.

W tym przykładzie TAG jest stałą String. Nie potrzebujesz unikalnej instancji obiektu String dla każdej instancji LoginFragment, więc zdefiniuj ją w obiekcie towarzyszącym:

class LoginFragment : Fragment() {

    ...

    companion object {
        private const val TAG = "LoginFragment"
    }
}

Możesz zdefiniować TAG na najwyższym poziomie pliku, ale może on też zawierać dużą liczbę zmiennych, funkcji i klas zdefiniowanych na najwyższym poziomie. Obiekty towarzyszące pomagają łączyć zmienne, funkcje i definicję klasy bez odwoływania się do żadnego konkretnego wystąpienia tej klasy.

Przekazywanie dostępu do usługi

Podczas inicjowania właściwości możesz powtarzać popularne wzorce w Androidzie, np. uzyskiwanie dostępu do ViewModel w obrębie Fragment. Aby uniknąć nadmiernego duplikowania kodu, możesz użyć składni przekazywania właściwości stosowanej przez Kotlin.

private val viewModel: LoginViewModel by viewModels()

Przekazywanie usług to typowa implementacja, której możesz używać wielokrotnie w całej aplikacji. Android KTX zapewnia dostęp do przedstawicieli niektórych usług. Na przykład viewModels pobiera wartość ViewModel ograniczoną do bieżącego Fragment.

Przekazywanie dostępu do usługi korzysta z refleksji, co zwiększa wymagania związane z wydajnością. W efekcie otrzymuje się zwięzłą składnię, która pozwala zaoszczędzić czas podczas programowania.

Dopuszczalność wartości null

Kotlin zapewnia rygorystyczne reguły dopuszczalności wartości null, które zapewniają bezpieczeństwo typu w całej aplikacji. W Kotlin odwołania do obiektów domyślnie nie mogą zawierać wartości null. Aby przypisać wartość null do zmiennej, musisz zadeklarować typ zmiennej z wartością null, dodając na końcu typu podstawowego ?.

Na przykład poniższe wyrażenie jest niedozwolone w Kotlin. name jest typu String i nie może zawierać wartości null:

val name: String = null

Aby zezwolić na wartość null, musisz użyć dopuszczalnego typu String (String?), jak pokazano w tym przykładzie:

val name: String? = null

Interoperacyjność

Dzięki rygorystycznym regułom Kotlin Twój kod jest bezpieczniejszy i bardziej zwięzły. Reguły te zmniejszają szanse na wystąpienie błędu NullPointerException, który spowoduje awarię aplikacji. Zmniejszają też liczbę null testów, które trzeba wykonać w kodzie.

Często podczas pisania aplikacji na Androida trzeba wywołać kod w języku innym niż Kotlin, ponieważ większość interfejsów API dla Androida jest napisana w języku programowania Java.

Dopuszczalność wartości null to kluczowy obszar, w którym Java i Kotlin różnią się w działaniu. Java jest mniej rygorystyczna ze składnią wartości null.

Na przykład klasa Account ma kilka właściwości, w tym właściwość String o nazwie name. W Javie nie ma reguł Kotlin dotyczących wartości null. Zamiast tego polega na opcjonalnych adnotacjach dotyczących wartości null, aby wyraźnie zadeklarować, czy możesz przypisać wartość null.

Platforma Androida jest napisana głównie w języku Java, więc taki scenariusz może się przytrafić przy wywoływaniu interfejsów API bez adnotacji o wartości null.

Typy platform

Jeśli używasz kodu Kotlin, aby odwołać się do elementu name bez adnotacji, który jest zdefiniowany w klasie Java Account, kompilator nie wie, czy String jest mapowany w Kotlin na String czy String?. Jest to niejasne za pomocą typu platformy (String!).

String! nie ma specjalnego znaczenia dla kompilatora Kotlin. String! może reprezentować String lub String?, a kompilator umożliwia przypisanie wartości każdego z tych typów. Pamiętaj, że istnieje ryzyko wywołania funkcji NullPointerException, jeśli prezentujesz typ jako String i przypisujesz wartość null.

Aby rozwiązać ten problem, używaj adnotacji z wartością null za każdym razem, gdy piszesz kod w Javie. Te adnotacje pomagają programistom w Javie i Kotlin.

Oto na przykład klasa Account zdefiniowana w języku Java:

public class Account implements Parcelable {
    public final String name;
    public final String type;
    private final @Nullable String accessId;

    ...
}

Jedna ze zmiennych składowych, accessId, jest oznaczona adnotacją @Nullable, co wskazuje, że może zawierać wartość null. Kotlin będzie wtedy traktować accessId jak String?.

Aby wskazać, że zmienna nigdy nie może mieć wartości null, użyj adnotacji @NonNull:

public class Account implements Parcelable {
    public final @NonNull String name;
    ...
}

W tym scenariuszu name w Kotlin jest uznawany za niedopuszczalną wartość String.

Adnotacje o wartości null pojawiają się we wszystkich nowych interfejsach API Androida oraz w wielu dotychczasowych. Wiele bibliotek Java dodało adnotacje o dopuszczalności wartości null, aby lepiej obsługiwać aplikacje zarówno w języku Kotlin, jak i w języku Java.

Obsługa dopuszczalności wartości null

Jeśli nie masz pewności, co do typu Javy, to znaczy, że może mieć wartość null. Na przykład element name klasy Account nie ma adnotacji, dlatego należy założyć, że jest to String z wartością null.

Jeśli chcesz przyciąć element name, tak aby jego wartość nie zawierała spacji na początku ani na końcu, możesz użyć funkcji trim Kotlina. String? możesz bezpiecznie przyciąć na kilka sposobów. Jednym z tych sposobów jest użycie operatora asertion not-null !!, jak pokazano w tym przykładzie:

val account = Account("name", "type")
val accountName = account.name!!.trim()

Operator !! traktuje wszystkie dane po lewej stronie jako wartości bez wartości null, więc w tym przypadku argument name będzie traktowany jako niepusty String. Jeśli wynik wyrażenia po lewej stronie ma wartość null, aplikacja zgłasza NullPointerException. Ten operator jest szybki i łatwy, ale należy go używać z umiarem, ponieważ może powodować ponowne wprowadzanie wystąpień NullPointerException do kodu.

Bezpieczniejszym rozwiązaniem jest użycie operatora bezpiecznego połączenia (?.), jak pokazano w tym przykładzie:

val account = Account("name", "type")
val accountName = account.name?.trim()

W przypadku użycia operatora bezpiecznego połączenia, jeśli name nie ma wartości null, wynikiem name?.trim() jest wartość nazwy bez spacji na początku ani na końcu. Jeśli name ma wartość null, wynikiem funkcji name?.trim() jest null. Oznacza to, że aplikacja nie może nigdy zgłosić wywołania NullPointerException podczas wykonywania tej instrukcji.

Operator Safe-call pozwala uniknąć ryzyka NullPointerException, ale przekazuje do następnej instrukcji wartość null. Zamiast tego możesz od razu obsługiwać zgłoszenia o wartości null, używając operatora Elvisa (?:), jak w tym przykładzie:

val account = Account("name", "type")
val accountName = account.name?.trim() ?: "Default name"

Jeśli wynik wyrażenia po lewej stronie operatora Elvisa ma wartość null, wartość po prawej stronie jest przypisywana funkcji accountName. Ta metoda przydaje się do podawania wartości domyślnej, która w innym przypadku miałaby wartość null.

Operator Elvis możesz też użyć, aby wcześniej wrócić z funkcji, jak w tym przykładzie:

fun validateAccount(account: Account?) {
    val accountName = account?.name?.trim() ?: "Default name"

    // account cannot be null beyond this point
    account ?: return

    ...
}

Zmiany w interfejsie Android API

Interfejsy API Androida są coraz bardziej przyjazne dla języka Kotlin. Wiele najczęstszych interfejsów API na Androida, w tym AppCompatActivity i Fragment, zawiera adnotacje z możliwością wartości null, a niektóre wywołania, takie jak Fragment#getContext, mają więcej zamienników przyjaznych dla języka Kotlin.

Na przykład uzyskanie dostępu do Context elementu Fragment ma prawie zawsze wartość zerową, ponieważ większość wywołań funkcji Fragment ma miejsce, gdy Fragment jest dołączony do Activity (podklasy Context). Mimo to Fragment#getContext nie zawsze zwraca wartość różną od null, ponieważ w sytuacjach, w których Fragment nie jest powiązana z Activity. Dlatego zwracany typ Fragment#getContext może mieć wartość null.

Ponieważ parametr Context zwrócony z funkcji Fragment#getContext może mieć wartość null (i jest opisany jako @Nullable), musisz go traktować w kodzie Kotlin jako Context?. Oznacza to zastosowanie jednego ze wspomnianych wcześniej operatorów do zareagowania na wartość null przed uzyskaniem dostępu do jego właściwości i funkcji. W niektórych z tych sytuacji Android zawiera alternatywne interfejsy API, które zapewniają taką wygodę. Fragment#requireContext zwraca na przykład niepustą Context i zwraca IllegalStateException, jeśli zostanie wywołana, gdy Context ma wartość null. Dzięki temu możesz traktować wynikowy Context jako niepusty i nie trzeba używać operatorów bezpiecznego połączenia ani obejścia.

Inicjowanie usługi

Właściwości w Kotlin nie są inicjowane domyślnie. Muszą one zostać zainicjowane podczas inicjowania klasy zamykającej.

Właściwości możesz zainicjować na kilka różnych sposobów. Z przykładu poniżej dowiesz się, jak zainicjować zmienną index, przypisując do niej wartość w deklaracji klasy:

class LoginFragment : Fragment() {
    val index: Int = 12
}

Inicjowanie można też zdefiniować w bloku inicjatora:

class LoginFragment : Fragment() {
    val index: Int

    init {
        index = 12
    }
}

W powyższych przykładach inicjowany jest index podczas tworzenia LoginFragment.

Niektóre właściwości mogą jednak nie zostać zainicjowane podczas tworzenia obiektu. Możesz np. odwoływać się do elementu View z poziomu elementu Fragment, co oznacza, że najpierw trzeba powiększyć układ. Inflacja nie występuje, gdy utworzony jest Fragment. Zamiast tego jest on zwiększany podczas wywoływania funkcji Fragment#onCreateView.

Jednym ze sposobów rozwiązania tego problemu jest zadeklarowanie widoku jako null i jak najszybsze jego zainicjowanie, jak pokazano w tym przykładzie:

class LoginFragment : Fragment() {
    private var statusTextView: TextView? = null

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

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView?.setText(R.string.auth_failed)
    }
}

Działa to zgodnie z oczekiwaniami, ale teraz musisz zarządzać wartością null w elemencie View, gdy tylko się do niego odwołasz. Lepszym rozwiązaniem jest użycie lateinit do inicjowania funkcji View, jak pokazano w tym przykładzie:

class LoginFragment : Fragment() {
    private lateinit var statusTextView: TextView

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

            statusTextView = view.findViewById(R.id.status_text_view)
            statusTextView.setText(R.string.auth_failed)
    }
}

Słowo kluczowe lateinit pozwala uniknąć inicjowania właściwości podczas tworzenia obiektu. Jeśli przed jej zainicjowaniem Kotlin odwołuje się do Twojej usługi, zgłasza UninitializedPropertyAccessException. Pamiętaj, by jak najszybciej ją zainicjować.