Android Kotlin Extensions to bardzo przydatny plugin, który uprzyjemnia pracę nad aplikacjami Androidowymi. Możemy z jego pomocą ułatwić sobie proces wiązania widoków z obiektami takimi jak Activities, czy też Fragments. Plugin umożliwia także automatyczną implementację interfejsu Parcelable dla wybranych obiektów. W artykule tym pokażę Wam jak można skorzystać z dobrodziejstw tego rozszerzenia, ale przede wszystkim wyjaśnię jak Kotlin Extensions działają pod spodem.
Stare na nowe
Android Kotlin Extensions tak naprawdę są już częścią standardowego pluginu Kotlina, jednak aby z nich skorzystać musimy jawnie określić taką chęć w pliku bulid.gradle (na poziomie modułu):
1 |
apply plugin: 'kotlin-android-extensions' |
Teraz wystarczy pozwolić Gradle zbudować projekt i wszystko mamy gotowe.
Każdy, kto choćby przez krótką chwilę maiła styczność z procesem tworzenia aplikacji dla Androida, doskonale zna poniższy sposób przypisywania widoków layout’u do poszczególnych zmiennych:
Layout:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="20dp" tools:context=".MainActivity"> <EditText android:id="@+id/editText" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="Enter Search Text" android:textAlignment="center" /> <Button android:id="@+id/button" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Check Search Count" /> <TextView android:id="@+id/textView" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAlignment="center" android:textSize="24sp" /> </LinearLayout> |
Obiekt typu Activity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class MainActivity : AppCompatActivity() { private var editText: EditText? = null private var searchButton: Button? = null private var textView: TextView? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) editText = findViewById(R.id.editText) searchButton = findViewById(R.id.button) textView = findViewById(R.id.textView) editText?.alpha = 0f searchButton?.alpha = 0f textView?.text = "Some new text" } } |
Można było oczywiście skorzystać z rozwiązań takich jak ButterKnife, ale nic nie oferowało takiego ułatwienia jak rozszerzenie przygotowane specjalnie dla Kotlina.
Powyższy przykład można teraz skrócić do tego:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) editText.alpha = 0f button.alpha = 0f textView.text = "Some new text" } } |
Prawda, że wygodniej? Stosowny import zostanie dodany automatycznie. Dzięki niemu kompilator będzie wiedział, że chcemy skorzystać z odpowiedniego rozszerzenia. Wszystko bardzo fajnie wygląda, ale warto zastanowić się co tak naprawdę kryje się za tą sztuczką 😵.
Kotlin Extensions od kuchni
Z Kotlinem i Javą w Androidzie jest taki sam problem jaki powstał jakiś czas temu w przypadku Swift’a oraz Objective-C. Z jednej strony nowy język jest znacznie przyjemniejszy dla oka, z drugiej jednak ukrywa on wiele ze szczegółów implementacji. W przypadku programistów z kilkuletnim stażem na karku problem nie wydaje aż się tak istotny, ponieważ istnieje duże prawdopodobieństwo, że w trakcie swojej pracy z Javą i Androidem (lub Objective-C i iOS) przerobili wszystko od podstaw. Inaczej ma się jednak sprawa z osobami, które dopiero zaczynają swoją przygodę z daną platformą. Muszą one wykazać odrobinę dobrej woli, aby zgłębiać metody, które powoli odchodzą w zapomnienie.
Wróćmy na chwilę do poprzedniego przykładu. Android Studio posiada bardzo przydatną funkcję, która pozwala nam sprawdzić jak będzie się prezentował nasz kod po kompilacji do bytecode’u. Wystarczy wybrać Tools -> Kotlin -> Show Kotlin Bytecode. Tak będzie prezentował się nasz przykład po konwersji (wersja mocno skrócona):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// ================MainActivity.class ================= // class version 50.0 (50) // access flags 0x31 public final class MainActivity extends android/support/v7/app/AppCompatActivity { // access flags 0x4 protected onCreate(Landroid/os/Bundle;)V @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0 L0 LINENUMBER 9 L0 ALOAD 0 ALOAD 1 INVOKESPECIAL android/support/v7/app/AppCompatActivity.onCreate (Landroid/os/Bundle;)V L1 LINENUMBER 10 L1 ALOAD 0 LDC 2131296283 INVOKEVIRTUAL MainActivity.setContentView (I)V L2 LINENUMBER 12 L2 ALOAD 0 GETSTATIC com/example/kotlinextensions/R$id.editText : I INVOKEVIRTUAL MainActivity._$_findCachedViewById (I)Landroid/view/View; CHECKCAST android/widget/EditText DUP LDC "editText" INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkExpressionValueIsNotNull (Ljava/lang/Object;Ljava/lang/String;)V FCONST_0 INVOKEVIRTUAL android/widget/EditText.setAlpha (F)V ... // access flags 0x2 private Ljava/util/HashMap; _$_findViewCache @Lkotlin/Metadata;(mv={1, 1, 9}, bv={1, 0, 2}, k=1, d1={"\u0000\u0018\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0008\u0002\n\u0002\u0010\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\u0018\u00002\u00020\u0001B\u0005\u00a2\u0006\u0002\u0010\u0002J\u0012\u0010\u0003\u001a\u00020\u00042\u0008\u0010\u0005\u001a\u0004\u0018\u00010\u0006H\u0014\u00a8\u0006\u0007"}, d2={"LMainActivity;", "Landroid/support/v7/app/AppCompatActivity;", "()V", "onCreate", "", "savedInstanceState", "Landroid/os/Bundle;", "production sources for module app"}) // compiled from: MainActivity.kt } |
Taki zapis jest oczywiście mało czytelny, ale za pomocą przycisku Decompile znajdującego się w górnej części okna, możemy skonwertować nasz kod do reprezentacji w Javie. Chwilę pewnie Wam zajmie przyzwyczajenie się do takiego zapisu, ale w końcu odnajdziecie znajome akcenty:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
public final class MainActivity extends AppCompatActivity { // Cache dla widoków private HashMap _$_findViewCache; protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(2131296283); EditText var10000 = (EditText)this._$_findCachedViewById(id.editText); Intrinsics.checkExpressionValueIsNotNull(var10000, "editText"); var10000.setAlpha(0.0F); Button var2 = (Button)this._$_findCachedViewById(id.button); Intrinsics.checkExpressionValueIsNotNull(var2, "button"); var2.setAlpha(0.0F); TextView var3 = (TextView)this._$_findCachedViewById(id.textView); Intrinsics.checkExpressionValueIsNotNull(var3, "textView"); var3.setText((CharSequence)"Some new text"); } public View _$_findCachedViewById(int var1) { if (this._$_findViewCache == null) { this._$_findViewCache = new HashMap(); } View var2 = (View)this._$_findViewCache.get(var1); if (var2 == null) { // Standardowa instrukcja przypisania var2 = this.findViewById(var1); this._$_findViewCache.put(var1, var2); } return var2; } public void _$_clearFindViewByIdCache() { if (this._$_findViewCache != null) { this._$_findViewCache.clear(); } } } |
Dzięki takiemu zapisowi możemy sprawdzić jak Kotlin Extensions działają w praktyce.
Bardzo ciekawa rzecz znajduje się niemal na samej górze. Jest to prywatne pole _$_findViewCache, które wykorzystuje HashMap do budowania cache dla naszych widoków. Dzięki temu operacja wyszukania zostanie wykonana tylko raz. Każde kolejne wywołanie spowoduje „wyciągnięcie” widoku z przechowalni. Oczywiście w ramach danego cyklu życia obiektu.
Druga bardzo ciekawa rzecz znajduje się w funkcji _$_findCachedViewById():
1 |
var2 = this.findViewById(var1); |
Jak się okazuje Kotlin nie wykosztuje żadnych magicznych sztuczek. Wiązanie widoków z danym obiektem odbywa się w dalszym ciągu na tych samych zasadach. Kotlin jedynie sprytnie ukrywa przed nami ten fakt. Podczas kompilacji zostaną wykonane standardowe operacje niewidoczne na pierwszy rzut oka. Warto również zwrócić uwagę na funkcję _$_clearFindViewByIdCache(), za pomocą której można wyczyści cache dla danego widoku. Przydatne w przypadku fragmentów, o czym będzie za chwilę.
Przejdziemy sobie teraz przez kilka przykładów wykorzystania Kotlin Android Extensions sprawdzając jednocześnie jak w rzeczywistości wygląda implementacja danej funkcjonalności.
Wiązanie widoków z Fragmentami
W pierwszym przykładzie pokazałem Wam jak KAE współpracują z obiektami Activty. Możemy z skorzystać z nich jednak także w przypadku Fragmentów. Użyjemy tego samego layout’u jak w przypadku Aktywności:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import kotlinx.android.synthetic.main.fragment_blank.* class BlankFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_blank, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) textView.text = "Some text on fragment" } } |
Sprawdźmy jak będzie prezentował się kod po dekompilacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
public final class BlankFragment extends Fragment { private HashMap _$_findViewCache; @Nullable public View onCreateView(@NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { Intrinsics.checkParameterIsNotNull(inflater, "inflater"); return inflater.inflate(2131296284, container, false); } public void onViewCreated(@NotNull View view, @Nullable Bundle savedInstanceState) { Intrinsics.checkParameterIsNotNull(view, "view"); super.onViewCreated(view, savedInstanceState); TextView var10000 = (TextView)this._$_findCachedViewById(id.textView); Intrinsics.checkExpressionValueIsNotNull(var10000, "textView"); var10000.setText((CharSequence)"Some text on fragment"); } public View _$_findCachedViewById(int var1) { if (this._$_findViewCache == null) { this._$_findViewCache = new HashMap(); } View var2 = (View)this._$_findViewCache.get(var1); if (var2 == null) { View var10000 = this.getView(); if (var10000 == null) { return null; } var2 = var10000.findViewById(var1); this._$_findViewCache.put(var1, var2); } return var2; } public void _$_clearFindViewByIdCache() { if (this._$_findViewCache != null) { this._$_findViewCache.clear(); } } // $FF: synthetic method public void onDestroyView() { super.onDestroyView(); this._$_clearFindViewByIdCache(); } } |
Różnicę możemy dostrzec na samym końcu. W chwili usunięcia widoku ze stack’u następuje automatyczne wyczyszczenie cache. Dzieje się tak dlatego, że w przypadku Fragmentów, widok może zostać w każdej chwili utworzony na nowo, natomiast sama instancja obiektu Fragment pozostanie nienaruszona. Widoki znajdujące się w cache będę już nieaktualne, więc najlepiej będzie utworzyć je na nowo.
Implementacja Parcelable
Od wersji 1.1.4 możemy również skorzystać z automatycznej implementacji Parcelable. Musimy jednak w bulid.gradle (ponownie na poziomie modułu) ustawić odpowiednią flagę, która da nam dostęp do funkcji traktowanych na tym etapie jako eksperymentalne:
1 2 3 |
androidExtensions { experimental = true } |
Korzystając z instrukcji @Pracelize możemy z łatwością przystosować niemal każdy obiekt do korzystania z Parcelable:
1 2 |
@Parcelize class Model(val title: String, val amount: Int) : Parcelable |
Plugin wykona za nas całą żmudą pracę. Tak będzie prezentował się kod po dekompilacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
@Parcelize public final class Model implements Parcelable { @NotNull private final String title; private final int amount; public static final android.os.Parcelable.Creator CREATOR = new Model.Creator(); @NotNull public final String getTitle() { return this.title; } public final int getAmount() { return this.amount; } public Model(@NotNull String title, int amount) { Intrinsics.checkParameterIsNotNull(title, "title"); super(); this.title = title; this.amount = amount; } public final int describeContents() { return 0; } public final void writeToParcel(@NotNull Parcel parcel, int flags) { Intrinsics.checkParameterIsNotNull(parcel, "parcel"); parcel.writeString(this.title); parcel.writeInt(this.amount); } @Metadata( mv = {1, 1, 9}, bv = {1, 0, 2}, k = 3 ) public static class Creator implements android.os.Parcelable.Creator { @NotNull public final Object[] newArray(int size) { return new Model[size]; } @NotNull public final Object createFromParcel(@NotNull Parcel in) { Intrinsics.checkParameterIsNotNull(in, "in"); return new Model(in.readString(), in.readInt()); } } } |
Dodanie obiektu do Intenta jest równie banalne:
1 2 3 |
val intent = Intent(this, SecondActivity::class.java) intent.putExtra(SecondActivity.EXTRA, model) startActivity(intent) |
@Parcelize wymaga, aby wszystkie pola klasy przeznaczone do serializacji, zostały umieszczone w głównym konstruktorze. Nie będziemy mogli skorzystać także z @Parcelize, jeżeli przynajmniej jeden z argumentów konstruktora nie będzie polem klasy.
Edycja cache
Korzystając z instrukcji @ContainerOptions możemy określi w jaki sposób będzie budowane cache dla naszych widoków. Możemy również całkowicie zrezygnować z tej opcji. Domyślną implementacją dla cache jest HashMap, ale możemy również skorzystać ze SparseArray. Tak na tą chwilę prezentują się dostępne opcje:
1 2 3 4 5 |
public enum class CacheImplementation { SPARSE_ARRAY, HASH_MAP, NO_CACHE; } |
A tak będzie wyglądała implementacja:
1 2 3 4 |
@ContainerOptions(CacheImplementation.SPARSE_ARRAY) class MainActivity : AppCompatActivity() { } |
Natomiast kod po dekompilacji będzie wyglądał tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public final class MainActivity extends AppCompatActivity { private SparseArray _$_findViewCache; public View _$_findCachedViewById(int var1) { if (this._$_findViewCache == null) { this._$_findViewCache = new SparseArray(); } View var2 = (View)this._$_findViewCache.get(var1); if (var2 == null) { var2 = this.findViewById(var1); this._$_findViewCache.put(var1, var2); } return var2; } public void _$_clearFindViewByIdCache() { if (this._$_findViewCache != null) { this._$_findViewCache.clear(); } } } |
Podczas budowaniu cache, HashMap zostało zastąpione przez strukturę SparseArray, która choć wolniejsza, to będzie potrzebowała mniejszych zasobów pamięci.
Słowo na drogę
Podobnych rozwiązań do Android Kotlin Extensions jest jeszcze kilka, jak na przykład Anko. Każde z nich ukrywa przed nami pewien poziom złożoności. Jak sami mogliście się przekonać, Kotlin Extensions swoje działanie opierają na prostych rozwiązaniach, które tylko z pozoru mogą wydawać się czymś skomplikowanym. Warto czasami prześwietlić dane narzędzie, choćby tylko z czystej ciekawości. Przy okazji nauczymy się czegoś nowego o platformie, na której pracujemy. Do następnego 🧐.