Projekt: Sterownik pasków RGBW cz. 2

Tak jak obiecałem w pierwszej częśći, dziś zajmiemy się stworzeniem aplikacji na system operacyjny Android, za pomocą której będziemy mogli łatwo i przyjemnie sterować kolorem podświetlenia w naszym sterowniku.

Ściągamy potrzebne narzędzia

Na początek musimy zaopatrzyć się w moduł bluetooth (np. taki jak ten) do sterownika oraz jakieś urządzenie z Androidem, na któym będziemy mogli testować naszą aplikację.

Aby móc pisać programy na system z zielonym robocikiem w logo będziemy musieli pobrać najnowsze JDK oraz jakieś środowisko, ja polecam wykorzystać do tego celu Android Studio bazujące na moim zdaniem najlepszym IDE do Javy, mianowicie IntelliJ Idea od JetBrains.

Android Studio jest dobrym wyborem także z uwagi na to, że już przy peirwszym uruchomieniu zainstaluje nam wszystkie niezbędne komponenty z androidowego SDK, więc naszym jedynym zmartwieniem będzie napisać działający kod :)

Kod do pobrania

W artykule nie będę omawiał całego kodu, a jedynie wybrane jego fragmenty, dlatego też polecam pobrać cały projekt i analizować go krok po kroku razem z artykułem :)

Link do repozytorium: https://bitbucket.org/Krupson/rgbstripdrivernettigo

Tworzymy nowy projekt

Krok 1: Musimy nadać nazwę naszej aplikacji

Krok 2: Teraz należy wybrać jaki będzie najstarszy system, na którym nasza aplikacja będzie działała. Możemy zostawić tutaj API level 19, gdyż zapewnia on wsparcie zdecydowanej większości urządzeń.

Krok 3: W kolejnym oknie będziemy mieli możliwość dodania gotowej aktywności do projektu. Wybierzmy Basic Activity. Dzięki temu Android Studio stworzy dla nas odpowiednią klasę oraz layouty dla niej.

Krok 4: Tutaj musimy wybrać nazwę dla aktywności, layoutu oraz pliku z danymi do menu. Zostawmy je tak jak są domyślnie. Klikamy Finish i środowisko przygotowuje nam projekt na którym będziemy mogli pracować.

Dodajemy aktywność

Nasza aplikacja powinna mieć ekran na któym wybierzemy sobie urządzenie z którym chcemy się połączyć. Dodjamy więc do projektu aktywność, która będzie za to odpowiedzialna.

Krok 1: Klikamy prawym przyciskiem myszy na folder z nazwą naszej paczki, wybieramy New > Activity > Empty Activity.

Krok 2: Nadajemy naszej aktywności nazwę SelectDeviceActivity. Android Studio sam ustali nazwę pliku z layoutem na podstawie nazwy aktywności.

Dostowujemy wygląd aplikacji

W folderze values znajdziemy kilka plików .xml. Jednym z nich jest colors.xml w którym możemy zapisać sobie kilka stałych kolorów. Znajdziemy tam też definicje kolorów motywu aplikacji, które możemy zmienić z domyślnych na takie, które nam się podobają. Ja wybrałem czarny i czerwony, Wy możecie sobie poeksperymentować i dobrać coś dla siebie :)

Kolejny interesujący dla nas plik to strings.xml. Powinniśmy umieścić w nim wszystkei ciągi znakowe z któych będziemy korzystali w aplikacji. Dziękie temu bedziemy mogli kiedyś zrobić sobie osobne pliki ze stringami dla wersji angielskiej, polskiej i jakiej tam jeszcze chcemy. Android będzie referował do tych ciągów znaków na podstawie ich identyfikatora.

Mój plik ze stringami wygląda tak:

<resources>
    <string name="app_name">RGB Strip Driver</string>

    <string name="bluetooth_select_device">Wybór urządzenia</string>
    <string name="bluetooth_state_no_connection">Status: Brak połączenia</string>
    <string name="bluetooth_state_connecting_with">Status: Łączenie z %1$s̴</string>
    <string name="bluetooth_state_connected_with">Status: Połączono z %1$s</string>

    <string name="hue">Kolor</string>
    <string name="saturation">Nasycenie</string>
    <string name="value">Jasność</string>
    <string name="color_preview">Podgląd koloru</string>

    <string name="menu_instant_apply">Zastosuj kolor natychmiast</string>
    <string name="menu_animation_start">Uruchom animację</string>
</resources>

%1$s to po prostu pierwszy parametr, któy będzie typu string. Chodzi o to, że w różnych językach dany wyraz może być w różnych częściach zdania. W polskim jakiś wyraz może być na początku, kiedy w angielskim tłumaczeniu ten wyraz będzie na końcu.

Kolejny xml do którego warto zajrzeć to menu_main.xml z folderu menu. Znajdują się tam pozycje, które zostaną wyświetlone w menu, któe znajduje się w prawym górnym rogu aplikacji.

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="eu.krupson.rgbstripdriver.MainActivity">

    <item
        android:id="@+id/action_instant_apply"
        android:orderInCategory="100"
        android:title="@string/menu_instant_apply"
        android:checkable="true"
        android:checked="true"
        app:showAsAction="never" />

    <item
        android:id="@+id/action_animation_start"
        android:orderInCategory="100"
        android:title="@string/menu_animation_start"
        app:showAsAction="never" />
</menu>

Należy zwrócić tutaj uwagę na fakt, że atrybut tools:context referuje do naszej MainActivity. Więc jeżeli macie inną nazwę paczki, a kopiujecie tego XMLa stąd to warto zmienić ten atrybut ;)

Druga sprawa to to, że w tagach item znajdziemy atrybut android:title, który jest referencją do stringów z pliku strings.xml. Można wpisać tutaj oczywiście dowolny tekst na sztywno, ale nie powinno się tego robić ze względu na wcześniej wspomnianą przeze mnie możliwość późniejszego tłumaczenia aplikacji.

Rzućmy teraz okiem na folder layouts. Znajdują się tam pliki w któych definiujemy wygląd naszej aplikacji. Jakie mamy wyświetlać elementy oraz jak mają być one ułożone. Jest to temat rzeka, dlatego też zainteresowanych odsyłam do bardzo dobrze napisanej dokumentacji Androida. Tych, którzy po prostu chcą tworzyć proste apki do swoich projektów zapraszam natomiast do przeanalizowania zawartości plików layoutów w moim projekcie. XMLe te są na tyle czytelne, że każdy powinien spokojnie zrozumieć o co chodzi i załapać jak mniej więcej rysować takie layouty samemu.

Napiszę tutaj w skrócie za co odpowiedzialne są poszczególne XMLe w moim projekcie:

  • activity_main.xml – layout głównej aktywności. Zawiera w sobie m.in. tzw. Floating Action Button oraz AppBar, czyli górny pasek.
  • content_main.xml – layout zawierający zawartość głównej aktywności. Jest on dołączany w activity_main.xml. Równie dobrze mogłoby tego pliku nie być, a wszystko moglibyśmy wpisać w activity_main.xml ale taki podział zwiększa czytelność kodu.
  • activity_select_device.xml – layout aktywności wyboru urządzenia.
  • device_single.xml – layout informujący aplikację o tym jak ma wyglądać pojedynczy element listy na liście urządzeń.

Piszemy klasę obsługującą połączenia Bluetooth

Aktualnie połączenie bluetooth w naszej aplikacji będzie obsługiwała tylko aktywność MainActivity. Jednakże w miarę późniejszego rozrostu aplikacji moglibyśmy chcieć mieć kilka ekranów które mogą wysyłać różne dane przez ten interfejs. Nie będziemy robili przecież miliona połączeń dla każdego ekranu. Zróbmy więc sobie klasę, która będzie obsługiwała połączenie i wysyłanie danych przez bluetooth i będzie można zrobić w całej aplikacji tylko jedną jej instancję. Jest to wzorzec projektowy, który nazywa się singleton.

Kliknijmy więc prawym przyciskiem na nazwę paczki, wybierzmy New > Java Class i ustawmy ją w następujący sposób:

Będzie to klasa publiczna, finalna. Nie chcemy aby można było po niej dziedziczyć. Jest to ostateczna klasa zapewniająca możliwość łączenia się przez bluetooth w naszej aplikacji.

Aby klasa była singletonem musimy stworzyć w niej statyczne pole finalne typu BluetoothConnection o nazwie instance, w któym umieścimy obiekt klasy BluetoothConnection. Kolejnym wymogiem, jest aby konstruktor takiej klasy był prywatny. Dzięki temu nikt poza klasą samą w sobie nie będzie mógł kontrolować ilości jej instancji. Teraz pozostaje już nam tylko zaimplementować statyczną, publiczną metodę getInstance, zwróci nam referencję do obiektu, na który wskazuje pole instance.

 

package eu.krupson.rgbstripdriver;

public final class BluetoothConnection {
    private static final BluetoothConnection instance = new BluetoothConnection();

    static BluetoothConnection getInstance() {
        return instance;
    }

    private BluetoothConnection() {
        
    }
}

Większość metod w naszej klasie da się bez problemu zrozumieć czytając ich zawartość. Początkującym może sprawić trudność metoda connect(). Tworzy ona instancję klasy wewnętrznej BluetoothConnectionAsync i wywołuje jej metodę execute(). Co tu się tak naprawdę dzieje?

Aby połączyć się przez bluetooth należy wykonać czynności zdefiniowane w metodzie doInBackground() klasy BluetoothConnectionAsync. Możemy zrobić to bezpośrednio w metodzie connect() i całkowicie odpuścić sobie tworzenie jakiejśtam klasy wewnętrznej. Jednak w takim przypadku przy każdej próbie połączenia nasza aplikacja będzie się wieszała. Będzie to spowodowane tym, że połączenie będzie wykonywane w wątku głównym aplikacji. Aby uniknąć lagów przy łączeniu należy zrobić to asynchronicznie – w tle. Służy do tego m.in. klasa AsyncTask, która może rozszerzać naszą klasę i uczynić ją możliwą do wykonania w tle.

private class BluetoothConnectionAsync extends AsyncTask<Void, Void, Void> {
    @Override
    protected Void doInBackground(Void... params) {
        try {
            BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(addr);
            btSocket = device.createInsecureRfcommSocketToServiceRecord(myUUID);
            btSocket.connect();
        } catch(Exception e) {
            return null;
        }
        return null;
    }

    @Override
    protected void onPostExecute(Void aVoid) {
        super.onPostExecute(aVoid);
        if(btSocket != null && btSocket.isConnected()) {
            if(connectionStatusListener != null) {
                connectionStatusListener.setConnectedWith(name);
            }
        } else {
            if(connectionStatusListener != null) {
                connectionStatusListener.setState(R.string.bluetooth_state_no_connection);
            }
        }
    }
}

 

public void connect(String name, String addr) {
    setAddr(addr);
    setName(name);

    if(connectionStatusListener != null) {
        connectionStatusListener.setConnectingWith(name);
    }
    new BluetoothConnectionAsync().execute();
}

 Komunikujemy się z innymi obiektami

Bardziej spostrzegawczy czytelnicy zauważyli pewnie, że w naszej klasie BluetoothConnection odwołujemy się do obiektu connectionStatusListener. Chodzi o to, aby klasa A poinformowała klasę B, kiedy klasa A skończy wykonywać pewną akcję. Przykład z aplikacji:

Mamy naszą aktywność MainActivity, która zajmuje się m.in. odświeżaniem interfejsu. Chcemy, aby klasa BluetoothConnection po tym jak uda jej się połączyć z urządzeniem (bądź też nie) poinformowała o tym MainActivity. W tym celu zrobimy sobie interfejs o nazwie ConnectionStatusListener.

Jak zwykle klikamy prawym na naszą paczkę, wybieramy New > Java Class (interfejs jest klasą tak jak wszystko w Javie) i ustawiamy okienko w następujący sposób:

Kod naszego interfejsu powinien wyglądać tak:

package eu.krupson.rgbstripdriver;

interface ConnectionStatusListener {
    void setState(int resourceId);
    void setConnectedWith(String name);
    void setConnectingWith(String name);
}

Jak widać deklaruje on 3 metody. Pierwsza z nich ustala ogólny stan i przyjmuje ID stringa z pliku strings.xml. Kolejne dwie ustawiają nazwę urzdzenia z którym udało się połączyć lub jest w trakcie łączenia.

Teraz jeżeli chcemy, aby nasze MainActivity mogło przyjmować wiadomości wysyłane za pomocą tego interfejsu musimy dopisać do nagłówka klasy implements ConnectionStatusListener oraz zaimplementować metody interfejsu.

public class MainActivity extends AppCompatActivity implements ConnectionStatusListener

 

@Override
public void setState(int resourceId) {
    connectionStatus.setText(getString(resourceId));
}

@Override
public void setConnectedWith(String name) {
    String state = String.format(getString(R.string.bluetooth_state_connected_with), name);
    connectionStatus.setText(state);
    try{
        btConnection.sendData("H="+seekHue.getProgress()+";S="+seekSat.getProgress()+";V="+seekVal.getProgress()+";GO");
    } catch(IOException e) {
        Toast.makeText(getApplicationContext(), e.getMessage(), Toast.LENGTH_SHORT).show();
    }
}

@Override
public void setConnectingWith(String name) {
    String state = String.format(getString(R.string.bluetooth_state_connecting_with), name);
    connectionStatus.setText(state);
}

Możemy zauważyć, że po udanym połączeniu z urządzeniem próbujemy wysłać do niego ciąg znaków ustawiający na sterowniku kolor taki jaki mamy aktualnie na paskach postępu (żeby nie było rozbieżności).

Uruchamiamy inne aktywności

W klasie MainActivity znajdziemy kod odpowiedzialny za ustawienie onClickListenera na FloatingActionButton, a w nim taki oto fragment:

Intent intent = new Intent(MainActivity.this, SelectDeviceActivity.class);
startActivityForResult(intent, 1);

Mówi on naszej aplikacji, że chcemy uruchomić teraz aktywność o nazwie SelectDeviceActivity w kontekście MainActivity.

Kolejna linijka uruchamia daną aktywność i mówi aplikacji, że oczekuje od tej aktywności jakiejś odpowiedzi opatrzonej identyfikatorem 1.

W klasie, która ma zwrócić dane musimy dopisać następujac kod:

setResult(Activity.RESULT_OK, new Intent().putExtra("BT_NAME", item.getName()).putExtra("BT_ADDR", item.getAddress()));
finish();

Chyba nie trzeba go szczegółowo analizować. Dodajemu tutaj wartości opatrzone nazwami BT_NAME oraz BT_ADDR (są to identyfikatory, którymi posłużymy się przy odczytywaniu wartości w MainActivity). Wywołanie finish() kończy działanie aktywności.

Aby odebrać dane, które zwróciła wywoływana aktywność musimy przeciążyć metodę onActivityResult w klasie MainActivity.

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (resultCode == Activity.RESULT_OK && requestCode == 1) {
        String bluetoothName = data.getStringExtra("BT_NAME");
        String bluetoothAddress = data.getStringExtra("BT_ADDR");

        btConnection.connect(bluetoothName, bluetoothAddress);
    }
}

I tak oto mamy zaimplementowaną komunikację między aktywnościami.

Tworzymy listę

Ostatnią rzeczą jaką musimy zrobić jest wyświetlenie listy urządzeń bluetooth. Aby to zrobić będziemy potrzebowali tak zwanego modelu do każdego elementu listy. Znajduje się on w klasie DeviceItem

package eu.krupson.rgbstripdriver;

public class DeviceItem {
    private String name;
    private String address;

    public DeviceItem(String name, String address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return this.name;
    }

    public String getAddress() {
        return this.address;
    }
}

Jak widać nie ma tu nic oprócz konstruktora i i getterów. Ta klasa ma być kontenerem dla naszych danych.

Aby móc wyświetlić listę takich modeli w elemencie ListView musimy stworzyć odpowiedni adapter. Jest to klasa, która będzie wiązała nam odpowiednie dane z odpowiednimi polami w widoku.

W naszym projekcie nazywa się ona DeviceListAdapter

package eu.krupson.rgbstripdriver;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.TextView;

import java.util.ArrayList;

public class DeviceListAdapter extends BaseAdapter {

    private LayoutInflater inflater;
    private ArrayList<DeviceItem> objects;

    private class ViewHolder {
        TextView name;
        TextView address;
    }

    public DeviceListAdapter(Context context, ArrayList<DeviceItem> objects) {
        inflater = LayoutInflater.from(context);
        this.objects = objects;
    }

    @Override
    public int getCount() {
        return objects.size();
    }

    @Override
    public DeviceItem getItem(int position) {
        return objects.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        ViewHolder holder;
        if(convertView == null) {
            holder = new ViewHolder();
            convertView = inflater.inflate(R.layout.device_single, null);
            holder.name = (TextView) convertView.findViewById(R.id.textDeviceName);
            holder.address = (TextView) convertView.findViewById(R.id.textDeviceAddress);
            convertView.setTag(holder);
        } else {
            holder = (ViewHolder) convertView.getTag();
        }
        holder.name.setText(objects.get(position).getName());
        holder.address.setText(objects.get(position).getAddress());
        return convertView;
    }
}

Cała magia dzieje się w metodzie getView, która pobiera odpowiedni layout (device_single.xml), wypełnia go danymi a następnie zwraca w postaci widoku. Taki widok zostaje dołączony jako element listy.

W klasie SelectDeviceActivity musimy jeszcze poinformować aplikację jakeigo adaptera będziemy używali i jakie dane chcemy wyświetlić:

ListView devices = (ListView) findViewById(R.id.devices_list);
Set<BluetoothDevice> pairedDevices = BluetoothConnection.getInstance().getDevicesList();

ArrayList<DeviceItem> objects = new ArrayList<>();
DeviceListAdapter deviceAdapter = new DeviceListAdapter(this, objects);

for(BluetoothDevice device:pairedDevices) {
    objects.add(new DeviceItem(device.getName(), device.getAddress()));
}

assert devices != null;
devices.setAdapter(deviceAdapter);

Teraz mając już gotowe „klocki” możemy łatwo samodzielnie dobudować resztę aplikacji, czyli jakie dane kiedy wysyłać, co robić w przypadku przesunięcia suwaka itp. Zachęcam spróbować napisać taką aplikację samemu lub dokładnie przeanalizować tą, którą napisałem ja.

Efekt końcowy