UNIVERSITATEA „ALEXANDRU IOAN CUZA” IAŞI
FACULTATEA DE INFORMATICĂ
LUCRARE DE LICENŢĂ
Dezvoltarea unei aplicații mobile pentru
comunicare vocală și textuală prin
intermediul internetului
propusă de
Dudu Mihai-Ştefan
Sesiunea: iulie, 2016
Coordonator ştiinţific
Asist. Dr. Vasile Alaiba
2
UNIVERSITATEA „ALEXANDRU IOAN CUZA” IAŞI
FACULTATEA DE INFORMATICĂ
Dezvoltarea unei aplicații mobile pentru
comunicare vocală și textuală prin
intermediul internetului
Dudu Mihai-Ştefan
Sesiunea: iulie, 2016
Coordonator ştiinţific
Asist. Dr. Vasile Alaiba
3
DECLARAŢIE PRIVIND ORIGINALITATE ŞI RESPECTAREA
DREPTURILOR DE AUTOR
Prin prezenta declar că Lucrarea de licenţă cu titlul „Dezvoltarea unei aplicații mobile pentru
comunicare vocală și textuală prin intermediul internetului” este scrisă de mine și nu a mai fost
prezentată niciodată la o altă facultate sau instituţie de învăţământ superior din ţară sau
străinătate. De asemenea, declar că toate sursele utilizate, inclusiv cele preluate de pe
Internet, sunt indicate în lucrare, cu respectarea regulilor de evitare a plagiatului:
toate fragmentele de text reproduse exact, chiar și în traducere proprie din altă limbă,
sunt scrise între ghilimele și deţin referinţa precisă a sursei;
reformularea în cuvinte proprii a textelor scrise de către alţi autori deţine referinţa
precisă;
codul sursă, imaginile etc. preluate din proiecte open-source sau alte surse sunt
utilizate cu respectarea drepturilor de autor și deţin referinţe precise;
rezumarea ideilor altor autori precizează referinţa precisă la textul original.
Iaşi,
Absolvent Dudu Mihai-Ștefan
___________________________
4
DECLARAŢIE DE CONSIMŢĂMÂNT
Prin prezenta declar că sunt de acord ca Lucrarea de licență cu titlul „Dezvoltarea unei
aplicații mobile pentru comunicare vocală și textuală prin intermediul internetului”, codul
sursă al programelor și celelalte conţinuturi (grafice, multimedia, date de test etc.) care
însoţesc această lucrare să fie utilizate în cadrul Facultăţii de Informatică.
De asemenea, sunt de acord ca Facultatea de Informatică de la Universitatea „Alexandru
Ioan Cuza” Iași să utilizeze, modifice, reproducă şi să distribuie în scopuri necomerciale
programele-calculator, format executabil şi sursă, realizate de mine în cadrul prezentei
lucrări de licenţă.
Iaşi,
Absolvent Dudu Mihai-Stefan
_________________________
5
Introducere și motivație
În această lucrare propun o soluție (atât pentru client cât și pentru server) pentru
dezvoltarea unei aplicații mobile care să faciliteze procesul de comunicare textuală și
vocală prin intermediul internetului.
Popularitatea acestui tip aplicații a crescut enorm în ultima vreme, creșterea fiind
sustinută atât de dorința utilizatorului de a comunica mai ușor și mai ieftin dar și de
investițiile dezvoltatorilor importanți de pe piața. Unele estimări arată că aplicațiile de
mesagerie au întrecut (ca număr de utilizatori activi lunar) chiar aplicațiile rețelelor
sociale1. Alți factori importanți sunt faptul ca acest tip de aplicație oferă utilizatorului
posibilitatea de a comunica cu alți utilizatori aflați chiar în alte țări fără a plăti taxe
suplimentare ca în cazul convorbirilor tradiționale, aceste aplicații utilizând internetul ca
suport și disponibilitatea internetului mobil este în continuă creștere (rețelele wireless care
oferă internet gratuit au o disponibilitate foarte mare acum).
Pentru a ajunge la un număr cât mai mare de utilizatori de dispozitive mobile am
hotarât implementarea clientului pe platforma Android întrucât statisticile arată că Android
detine peste 70% din piața dispozitivelor mobile2.
Având astfel o idee despre popularitatea acestui tip de aplicații este de așteptat ca în
eventualitatea intrării pe piață cu o astfel de aplicație aceasta trebuie să respecte anumite
cerințe ca să poată fi competitivă:
utilizarea aplicației să fie intuitivă;
să accelereze procesul de comunicare;
să nu utilizeze prea multe resurse (resurse energetice limitate);
să fie stabilă în condiții de încărcare mare.
În capitolele ce urmează am prezentat modalitățile identificate de mine a căror utilizare
duce la îndeplinirea cerințelor enumerate.
1 Popularitatea aplicațiilor de mesagerie - http://www.businessinsider.com/the-messaging-app-report-
2015-11 2 Popularitatea platformei Android - https://www.netmarketshare.com/operating-system-market-
share.aspx?qprid=8&qpcustomd=1
6
Contribuții
Am decis împărțirea lucrării în două capitole mari care tratează, pe rând, procesul de
dezvoltare din cele două perspective (client și server) motivând deciziile luate pe parcursul
procesului de dezvoltare, oferind detalii de implementare și unele comparații între
implementări diferite ale aceleași componente:
Capitolul 1: Dezvoltarea aplicației client pe platforma Android
Capitolul 2: Dezvoltarea aplicației server
În procesul de dezvoltare al clientului am analizat diverse tehnici pentru a minimiza
amprenta aplicației asupra resurselor clientului dar fără să afecteze negativ calitatea
procesului de comunicare al utilizatorului și am ales metodele pe care le-am considerat
benefice:
Utilizarea fragmentelor în procesul de implementare al interfeței grafice;
Utilizarea unui serviciu care rulează în fundal;
Încărcarea asincronă a anumitor părți necesare interfeței grafice;
Implementarea unui codec audio pentru a economisi banda de internet;
Implementarea anumitor componente în C/C++ pentru a reduce consumul de
resurse.
Dezvoltarea aplicației server a avut în vedere faptul că este necesar ca aplicația să făcă față,
unui număr mare de utilizatori astfel că am analizat diverse posibilități pentru a obține
acest lucru și am ajuns la o implementare ce utilizeaza:
Un server TCP asincron implementat cu epoll3;
Procesarea asincrona a cererilor;
Un sistem asincron de lansare și executare a interogărilor.
Implementarea în mod asincron a diverselor componente a dus la creșterea numărului de
cereri procesate concurent de către server, crescând astfel implicit și capacitatea serverului.
3epoll - http://linux.die.net/man/4/epoll
7
Cuprins
Introducere și motivație ............................................................................................. 5
Contribuții ................................................................................................................... 6
Capitolul 1: Dezvoltarea aplicației client pe platforma Android ........................... 9
1.1. Arhitectura generală a aplicației .................................................................... 9
1.2. Interfață grafică ............................................................................................. 10
1.2.1. Fereastra de autentificare/înregistrare ................................................. 11
1.2.2. Fereastra principală ............................................................................... 12
1.2.3. Fereastra pentru setări........................................................................... 13
1.2.4. Fereastra pentru selectarea imaginii de profil ..................................... 13
1.2.5. Fereastra de apel..................................................................................... 15
1.2.6. Fereastra de conversație text ................................................................. 15
1.2.7. Incărcarea asincrona a imaginilor de profil ........................................ 16
1.3. Serviciu permanent ce rulează în fundal ..................................................... 17
1.4. Comunicarea între serviciu și activitate ...................................................... 19
1.5. Protocolul de comunicare între client și server .......................................... 20
1.6. Java Native Interface (JNI) .......................................................................... 21
1.6.1. Clientul TCP ........................................................................................... 21
1.6.2. OpenSL ES pe platforma Android ....................................................... 22
1.6.3. Preluarea datelor de la microfon și redarea acestora ......................... 23
1.6.4. Comprimarea datelor audio .................................................................. 23
1.6.5. Concluzii .................................................................................................. 28
Capitolul 2: Dezvoltarea aplicației server .............................................................. 30
2.1. Arhitectura generala a aplicației .................................................................. 30
2.2. Detalii de implementare ................................................................................ 31
2.2.1. Serverul TCP .......................................................................................... 31
2.2.1.1. Sincron versus asincron .................................................................. 31
2.2.1.2. Tratarea concurentă a clienților .................................................... 32
2.2.1.3. Implementare cu epoll ..................................................................... 34
2.2.1.4. Recepționarea datelor în mod asincron ......................................... 35
8
2.2.1.5. Procesarea cererilor în mod asincron ............................................ 35
2.2.1.6. Expedierea datelor în mod asincron .............................................. 35
2.2.1.7. Concluzii ........................................................................................... 36
2.2.2. Baza de date persistentă ......................................................................... 36
2.2.2.1. Execuția clasică a interogărilor ...................................................... 36
2.2.2.2. Lansarea asincrona a interogărilor ............................................... 37
2.2.2.3. Interogări irelevante ........................................................................ 38
2.2.2.4. Concluzii ........................................................................................... 38
2.2.3. Server HTTP pentru imaginile de profil .............................................. 39
Concluzii generale .................................................................................................... 41
Bibliografie ................................................................................................................ 42
9
Capitolul 1: Dezvoltarea aplicației client pe platforma Android
Aplicația client are ca scop facilitarea procesului de comunicare textuală și vocală
între doi utilizatori de dispozitive mobile Android prin intermediul unei conexiuni la
internet. Această aplicație a fost dezvoltată cu ajutorul mediului de dezvoltare integrat
pentru platforma Android – Android Studio. Pe parcursul dezvoltării aplicației am urmărit
utilizarea unor tehnici specifice, descrise pe larg în subcapitolele următoare, astfel încât
aplicația finală să poată fi utilizata în condiții reale și consumul de resurse să fie redus.
1.1. Arhitectura generală a aplicației
Arhitectura unei aplicații de acest tip este diferită de cea a unei aplicații obișnuite.
Aplicația client trebuie să fie capabilă să ofere utilizatorului o experiență plăcută la
utilizare, să notifice utilizatorul de fiecare dată când este nevoie (în cazul în care acesta
primește un apel, mesaj sau cerere de contact) si fiindcă o astfel de aplicație are, de obicei,
un timp îndelungat de utilizare trebuie să folosească cu moderație resursele puse la
dispoziție de dispozitivul utilizatorului întrucât este bine-cunoscut faptul că dispozitivele
mobile contemporane dispun de resurse energetice relativ reduse.
Figura 1: Arhitectura generala a aplicației client
10
Pentru a îndeplini obiectivele enumerate mai sus am recurs cateva tehnici destul de
frecvent utilizate în randul dezvoltatorilor de aplicatii mobile pentru platforma Android si
nu numai:
Utilizarea obiectelor de tip Fragment4 pentru interfață grafica;
Incarcarea asincrona a anumitor parti din interfață grafica;
Portarea unor porțiuni de cod din Java în C/C++ (cod nativ);
Utilizarea unui serviciu ce va rula în permanenta în fundal.
Astfel am ajuns la arhitectura ilustrata în Figura 1:
1.2. Interfață grafică
Utilizatorul are la dispoziție o interfață grafică simplă și intuitivă, care utilizează pe
cât posibil pictograme sugestive și gesturile utilizatorului pentru a face nagivarea în cadrul
aplicației cât mai simplă și cursivă.
Pentru a păstra consumul de resurse al aplicației client la un nivel cât mai scăzut am
recurs la utilizarea obiectelor de tip Fragment în detrimentul activităților separate pentru
fiecare fereastră a aplicației. Obiectele de tip Fragment au o amprentă mult mai puțin
vizibilă asupra consumului de resurse, fiind reutilizabile în cadrul aplicației, și permit
crearea cu ușurința a unei interfețe grafice dinamice (de exemplu fereastra principală a
aplicației descrisă în subcapitolul 1.2.2) care poate chiar să îmbine mai multe fragmente în
cadrul aceleași activități. Similar activităților obiectele de tip Fragment au un ciclu de viață
propriu în cadrul aplicației dar reacționează și la evenimentele activității gazdă.
Pe lângă interfața grafică oferită de aplicație utilizatorul poate interacționa cu
aplicația și prin intermediul notificărilor lansate de serviciul care rulează în permanență în
fundal. Acest serviciu poate lansa trei tipuri de notificări:
Pentru mesaje primite;
Pentru apeluri primite (doar în cazul în care telefonul are ecranul aprins și este
deblocat);
Pentru cereri de contact.
Notificările pentru apeluri permit acceptarea sau respingerea apelului fără ca
utilizatorul să fie nevoit să-și întrerupă activitatea curentă.
4 Object Fragment - https://developer.android.com/training/basics/fragments/creating.html
11
1.2.1. Fereastra de autentificare/înregistrare
Fereastra de autentificare este fereastră de start a aplicației client. Acestă fereastră
permite utilizatorului să se autentifice sau să-și creeze un nou cont. În cazul în care
utilizatorul este deja autentificat dîntr-o sesiunea anteriora (terminarea unei sesiuni se
poate face din fereastră principală din meniul lateral) fereastra de autentificare va fi închisă
automat și va lansa fereastra principală.
Detaliile de autentificare sunt memorate în memoria persistentă a dispozitivului prin
intermediul interfeței SharedPreferences5. Verificarea existenței unei sesiuni active se face
prin intermediul serviciului ce rulează în fundal. În cazul în care acest serviciu a fost închis
sau conexiunea TCP a fost inchisă sesiunea se poate recupera efectuând o reautentificare
cu detaliile salvate cu ajutorul interfeței SharedPreferences.
Interfața SharedPreferences oferă acces spre citirea sau modificarea unor informații
salvate în prealabil. Pentru a păstra consistența valorilor modificările acestora trebuie
efectuate prin intermediul obiectului SharedPreferences.Editor. Accesul unor anumite
valori se face prin apelul la funcții get specifice tipului de date.
În Secțiunea de cod 1 poate fi observat procesul de salvare persistentă a datelor de
autentificare spre a fi utilizate ulterior la automatizarea autentificării.
public void saveLoginState(Context context, String email, String
password) {
SharedPreferences preferences =
context.getSharedPreferences("detalii_login", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("email", email);
editor.putString("password", password);
editor.commit();
}
Secțiunea de cod 1: Salvarea persistentă a datelor de autentificare
În Secțiunea de cod 2 poate fi observat procesul de obținere a datelor de autentificare
salvate, dacă este cazul.
5 SharedPreferences -
https://developer.android.com/reference/android/content/SharedPreferences.html
12
public void loadLoginSavedState(Context context) { SharedPreferences preferences = context.getSharedPreferences("detalii_login", Context.MODE_PRIVATE); String email = preferences.getString("email", null); String password = preferences.getString("password", null); if (email == null || password == null) { saveLoginState(context, null, null); } else { mHasSavedLoginState = true; mEmail = email; mPassword = password; } }
Secțiunea de cod 2: Obținerea unor date salvate
1.2.2. Fereastra principală
Fereastra principală folosește tranziții de tipul screen-slider pentru a facilita
navigarea rapidă și intuitivă între secțiunile principale ale aplicației. Aceste tranziții sunt
ușor accesibile programatorului prin intermediul componentei ViewPager6 și utilizarea
obiectelor de tip Fragment din Android Support Library7.
Fiecare secțiune vizibilă în fereastra principală are asociat un obiect de tip Fragment
care este responsabil pentru afișarea conținutului vizual corespunzător secțiunii sale.
Managementul tuturor acestor fragmente se face automat în cadrul componentei
ViewPager prin intermediul unui adaptor de tipul FragmentPagerAdapter8 modificat
corespunzător necesităților aplicației curente. Acest adaptor este responsabil de
inițializarea tuturor fragmentelor pe baza unei poziții numerice (numărul de ordine al
fragmentului curent). Fragmentele tuturor paginilor vizitate de utilizator sunt păstrate în
memorie, ceea ce ar putea rezulta într-un consum crescut de memorie în cazul în care am
avea un număr mare de pagini, dar ierarhie de viewuri poate fi distrusă cand fragmentele
nu sunt vizibile[1].
Din fereastra principală utilizatorul are acces și la un panou lateral care oferă
utilizatorului informații despre profilul său, acces la fereastra pentru setări dar și
posibilitatea de a părăsi sesiunea curentă.
6 ViewPager - https://developer.android.com/reference/android/support/v4/view/ViewPager.html 7 Android Support Library - https://developer.android.com/topic/libraries/support-library/index.html 8 FragmentPageAdapter -
https://developer.android.com/reference/android/support/v4/app/FragmentPagerAdapter.html
13
Figura 2: Panou lateral
1.2.3. Fereastra pentru setări
Prin intermediul acestei ferestre utilizatorul are posibilitatea de a controla
urmatoarele setări:
Rămânerea în fundal a aplicației după ce a fost închisă – on/off
Salvarea datelor de logare – on/off
Setarea sunetelor pentru notificare/apel
Persistența setărilor este asigurată cu ajutorul interfeței SharedPreferences care a fost
descrisă în detaliu în subcapitolul 1.2.1.
1.2.4. Fereastra pentru selectarea imaginii de profil
Această fereastră este compusă din două fragmente: unul dă posibilitatea
utilizatorului să aleagă sursa imaginii, al doilea oferă posibilitatea selectarii porțiunii din
imagine care va fi afișată drept imagine de profil.
Utilizatorul are la dispoziție două surse din care poate alege imaginea de profil:
Galeria foto a dispozitivului
Camera foto a dispozitivului
În ambele cazuri după selectarea/capturarea imaginii utilizatorului ii este afișat fragmentul
în care trebuie să aleagă o zonă de dimensiune fixă care să reprezinte imaginea sa de profil.
14
Figura 3: Selectarea zonei pentru imaginea de profil
Efectul de selecție a fost creat prin intermediul unui obiect View personalizat9
(CustomImageSelectionView). Mai exact am suprascris metodele onTouchEvent, pentru ca
utilizatorul sa poata muta „zona de interes” a imaginii dupa cum dorește, și onDraw pentru
a desena zona întunecată din exteriorul zonei de interes (Secțiunea de cod 3).
protected void onDraw(final Canvas canvas) { super.onDraw(canvas); canvas.clipPath(mCirclePath, Region.Op.DIFFERENCE); canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mBackgroundPaint); }
Secțiunea de cod 3: Desenarea zonei întunecate
A se observa ordinea efectuării operațiilor: întâi se creează o mască în formă de cerc cu
parametrul DIFFERENCE dupa care este desenat un dreptunghi pe toată dimensiunea
obiectului Canvas10. Din cauza maștii în formă de cerc, în procesul de desenare a
dreptunghiului zona maștii va fi ignorată.
9 Obiect View personalizat - https://developer.android.com/training/custom-views/index.html 10 Obiect Canvas - https://developer.android.com/reference/android/graphics/Canvas.html
15
1.2.5. Fereastra de apel
Fereastra de apel este afișata utilizatorului în trei cazuri:
Utilizatorul efectuează un apel;
Utilizatorul primește un apel;
Utilizatorul este angajat intr-un apel activ.
În cazul în care utilizatorul efectuează un apel sau este deja angajat într-un apel activ
ecranul dispozitivului va fi închis la apropierea acestuia de urechea utilizatorului, evitând
astfel apăsarea accidentală a vreunui control dar ajută și la economisirea energiei. Pentru a
realiza acest lucru este folosit un WakeLock11 care face uz de senzorul de proximitate al
dispozitivului pentru a închide/aprinde ecranul.
powerManager = (PowerManager) getSystemService(POWER_SERVICE); wakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, getLocalClassName()); wakeLock.setReferenceCounted(false); wakeLock.acquire();
Secțiunea de cod 4: Crearea și obținerea unui WakeLock pentru închiderea ecranului
Pentru ca Secțiunea de cod 4 să poată fi folosită este necesară declararea unor
permisiuni speciale în fișierul manifest.
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
Secțiunea de cod 5: Permisiunile necesare blocarii/deblocarii ecranului
1.2.6. Fereastra de conversație text
Pentru a afișa fundalul mesajelor text au fost folosite imagini 9-patch12. Aceste
imagini permit redimensionarea fundalului fără a introduce distorsionări. Acest lucru este
posibil prin împărțirea imaginii originale în 9 regiuni (Figura 1.2.4.2.).
Figura 4: Împărțirea pe regiuni a unei imagini 9-patch
11 WakeLock - https://developer.android.com/reference/android/os/PowerManager.WakeLock.html 12 Imagini 9-patch - https://software.intel.com/en-us/xdk/articles/android-splash-screens-using-nine-
patch-png
16
Regiunile numerotate cu 1, 3, 7 și 9 nu sunt redimensionate, regiunile numerotate cu
2 și 8 sunt redimensionate numai pe lățime, regiunile numerotate cu 4 și 6 sunt
redimensionate numai pe înălțime iar regiunea numerotata cu 5 este redimensionata atât pe
inălțime cât și pe lățime.
Imaginile 9-patch utilizate de către această aplicație au fost generate cu ajutorul
utilitarului disponibil în suita de dezvoltare pentru platforma Android. Acest utilitar
introduce o linie de un pixel grosime, atât pe verticală cât și pe orizontală, pentru a putea
identifica cu ușurință cele 9 regiuni.
Utilizarea imaginilor 9-patch pentru fundaluri nu este diferită de cea a imaginilor
obișnuite.
1.2.7. Incărcarea asincrona a imaginilor de profil
Imaginile de profil sunt asociate independent fiecărui utilizator în parte. Aplicația
client trebuie să procure imaginile de profil, corespunzătoare persoanelor din lista de
contacte a utilizatorului curent, de la un server HTTP extern. Acest lucru ar putea genera
întârzieri neprevăzute și nepredictibile în timpul încărcării interfeței grafice astfel că pentru
a evita blocarea aplicației am recurs la o metodă asincrona de incărcare a acestor imagini.
În acest mod threadul responsabil cu interfața grafică nu este blocat pe durata descărcării
imaginilor.
Încărcarea asincrona a imaginilor se face prin intermediul clasei singleton
ProfilePicManager. Această clasă are rolul de a manageria descărcarea imaginilor de pe
serverul HTTP extern prin încapsularea fiecarei cereri de descărcare într-un obiect de tipul
Runnable13 și executarea lor într-un thread pool de dimensiune variabilă, dimensiunea
maximă fiind de 4 threaduri.
În momentul în care este necesară descărcarea unei imagini (pentru a fi atribuită unui
obiect de tipul ImageView14) se trimit către ProfilePicManager un identificator numeric
reprezentând chiar identificatorul contactului (a cărei imagini de profil va fi descărcată),
obiectul ImageView țintă și un callback ce va fi apelat în momentul în care procesul de
descărcare a imaginii a luat sfârșit. Pentru fiecare persoană din lista de contacte a
utilizatorului poate există o singură cerere activă pentru descărcarea imaginii de profil la
un moment dat, astfel dacă mai multe obiecte ImageView au nevoie de aceeași imagine în
același timp nu este necesară crearea mai multor instanțe de obiecte ci doar adăugarea lor
13 Obiect Runnable - https://developer.android.com/reference/java/lang/Runnable.html 14 Obiect ImageView - https://developer.android.com/reference/android/widget/ImageView.html
17
într-o listă de așteptare și la momentul terminarii procesului de descarcarea toate obiectele
de tipul ImageView din lista de așteptare vor fi actualizate corespunzator.
Pentru a nu împiedica Garbage Collectorul15 să colecteze obiectele de tipul
ImageView care poate nu mai sunt utilizate, dar se află în lista de așteptare, vom reține
obiectele ca WeakReference16. Acest lucru ne permite să reținem o listă de obiecte
ImageView fără a ne face griji pentru eventuale probleme legate de Garbage Collector.
Dacă vreun obiect ImageView va fi colectat în timp ce se află în lista de așteptare acesta va
deveni null. Astfel la momentul actualizării obiectelor ImageView din listă vom verifica
obiectele daca sunt null și le vom ignora.
Obiectele ImageView care necesită descărcarea unor imagini de profil provin de
regulă dintr-un ListView ceea ce înseamnă că este posibil ca aceste obiecte să fi fost
reciclate. Acest lucru duce la o problemă destul de gravă – este posibil ca la momentul
terminării procesului de descărcare a unei imagini obiectul ImageView ce trebuie actualizat
să nu mai fie relevant. Pentru a verifica daca obiectul ImageView mai este relevant vom
utiliza etichete continând identificatorul contactului căruia îi corespunde imaginea de
profil. Eticheta este setată în momentul creării obiectului ImageView (și actualizată la
reciclare) iar verificarea se va face înaintea actualizării obiectului din lista de așteptare.
Pentru actualizarea propriu-zisă a obiectelor ImageView este trimisă o instanță a
clasei ImageViewUpdateTask spre a fi rulată în cadrul threadului responsabil cu interfața
grafică.
1.3. Serviciu permanent ce rulează în fundal
Aplicația client necesită o conexiune TCP deschisă mereu pentru a putea primi în
timp real informații de la server (atunci când utilizatorul primește un mesaj, un apel, o
cerere de contact) și pentru a păstra un consum scăzut de resurse am decis delegarea
managementului acestei conexiuni unui serviciu ce rulează în fundal chiar dacă activitatea
principală a aplicației este inchisă.
Crearea unui serviciu se face prin crearea unei clase ce extinde clasa de bază
Service17. Această clasă de bază oferă posibilitatea suprascrierii unor callbackuri astfel
încât să putem trata corect evenimentele dorite pe durata de viața a serviciului. Cele mai
importante callbackuri pentru aplicația de față sunt:
15 Garbage Collector - https://en.wikipedia.org/wiki/Garbage_collection_(computer_science) 16 WeakReference - https://developer.android.com/reference/java/lang/ref/WeakReference.html 17 Service - https://developer.android.com/guide/components/services.html
18
onStartCommand;
onBind.
Primul callback este apelat de sistem când o altă componentă a aplicației pornește serviciul
printr-un apel la funcția startService. Cel de-al doilea callback este apelat cand o
componentă vrea să creeze o legătură cu serviciul curent prin intermediul unui apel la
funcția bindService. Acest callback trebuie să întoarcă o interfața pe care cealaltă
componentă să o poată utiliza în procesul de comunicare descris mai în detaliu în
următorul subcapitol.
Pe lângă crearea clasei serviciului este necesară declararea sa și în cadrul fișierului
manifest:
<service android:name=".stalker.Stalker" android:enabled="true" />
Secțiunea de cod 6: Declararea unui serviciu
Perioada de viață a unui serviciu coincide deobicei cu durata de viață a aplicației. În
cazul de față se dorește păstrarea serviciului activ în fundal chiar și după închiderea
aplicației vizuale. Acest lucru este posibil prin specificarea valorii START_STICKY18 la
returnarea din funcția onStartCommand. Astfel dupa închiderea serviciului sistemul va
încerca să-l repornească. Închiderea serviciului ar putea rezulta din mai multe cauze:
aplicația a fost inchisă;
sistemul oprește serviciul pentru a conserva resurse.
Șansele ca serviciul să fie oprit pentru a conserva resurse ar putea fi scăzute considerabil
transformând serviciul nostru într-un serviciu foreground19.
Un serviciu foreground, spre deosebire de un serviciu background, presupune faptul
că utilizatorul este conștient de faptul că serviciul este activ astfel că sistemul nu va mai
opri serviciul atât de ușor pentru a conserva resurse. Un serviciu foreground trebuie să
lanseze o notificare care nu poate fi ascunsă sau inchisă decât cand serviciul iese din
foreground sau este oprit. Trimiterea unui serviciu în foreground se face printr-un apel la
funcția startForeground(), iar pentru a scoate un serviciu din foreground se apelează
funcția stopForeground(). Funcția stopForeground primește ca parametru o valoare
booleană care indica faptul că notificarea trebuie ștearsă.
Pentru aplicația curentă am decis utilizarea serviciului în background cât timp
aplicația este pornită și trecerea sa în foreground la momentul închiderii aplicației.
18 START_STICKY -
https://developer.android.com/reference/android/app/Service.html#START_STICKY 19 Foreground Service - https://developer.android.com/guide/components/services.html#Foreground
19
Utilizatorul va primi o notificare la închiderea aplicaîiei care îl va informa faptul că
serviciul este activ chiar daca aplicația este închisă.
1.4. Comunicarea între serviciu și activitate
Există mai multe modalitați de a realiza comunicarea între o activitate și un serviciu.
În cazul aplicației curente este de reținut faptul că serviciul și activitațile rulează în cadrul
aceluiași proces ceea ce elimină necesitatea utilizării tehnicilor IPC20. Acest lucru aduce
două beneficii:
Procesul de comunicare este mult simplificat (din punct de vedere al
programării);
Procesul de comunicare se realizează aproape instant (evitând astfel eventuale
întârzieri în timpul utilizării aplicației).
Legătura dintre activitate și serviciu se face printr-un apel la funcția bindService21
care primește ca parametru și un obiect de tipul ServiceConnection22 care ne oferă
informații cu privire la momentul stabilirii/închiderii conexiunii cu serviciul prin funcțiile:
onServiceConnected;
onServiceDisconnected.
Mai departe trebuie suprascrise metodele onBind și onUnbind din clasa serviciului. În
cazul de față metoda onBind returnează o instanță a unei clase ce extinde clasa de bază
Binder23 și care oferă acces la instanța curentă a serviciului (Secțiunea de cod 6).
public class LocalBinder extends Binder { public Stalker getService() { return Stalker.this; } }
Secțiunea de cod 7: Declarare binder local
În urma stabilirii conexiunii activitatea va avea acces la instanța clasei serviciului
curent și va putea efectua apeluri către metodele publice ale acestei clase, ca la orice clasa
normală.
20 IPC - https://developer.android.com/guide/components/processes-and-threads.html#IPC 21 Bound services - https://developer.android.com/guide/components/bound-services.html 22 ServiceConnection -
https://developer.android.com/reference/android/content/ServiceConnection.html 23 Binder - https://developer.android.com/reference/android/os/Binder.html
20
1.5. Protocolul de comunicare între client și server
Comunicarea între aplicația client și server se desfășoară prin intermediul unui canal
TCP deschis atât timp cât utilizatorul este autentificat. Am ales ca mesajele să fie transmise
în format binar și nu text pentru a reduce cât mai mult dimensiunea acestora și pentru a
evita parsarea de text pe dispozitivul mobil întrucât această parsare ar fi introdus un cost de
timp și memorie relativ ridicat comparativ cu cel al interpretării unui mesaj în format binar.
Un mesaj este format din:
Header cu dimensiunea de 8 octeți care conține dimensiunea mesajului și tipul
acestuia;
Conținutul mesajului cu dimensiunea de până la 4,294,967,287 de octeți.
Figura 5: Structura unui pachet de date (mesaj)
Transmisia tipurilor de date de baza (int, boolean etc.) se face destul de ușor fiindcă
dimensiunea reprezentării lor este cunoscută dar în cazul transmisiei datelor reprezentate
ca tablouri (de exemplu String) este necesară prefixarea secvenței de date cu dimensiunea
acestora pentru a putea fi citite corect.
Construcția acestor mesaje (în cod denumite pachete) este realizată în cadrul claselor
specifice fiecărui tip de pachet. În cazul mesajelor ce urmează a fi trimise către server
datele sunt scrise în format binar cu ajutorul unui obiect de tipul ByteArrayOutputStream24.
Obiectul de tipul OutgoingPacket rezultat în urma procesului de construcție este, prin
intermediului serviciului ce rulează în background, introdus într-o listă de așteptare spre a
fi transmis serverului.
În cazul mesajelor primite de la server mesajul în format binar este memorat cu
ajutorul unui obiect de tipul ByteBuffer25 din care se extrag, în ordinea corespunzătoare,
informațiile din pachet și sunt memorate pentru a fi utilizate ulterior.
24ByteArrayOutputStream -
https://docs.oracle.com/javase/7/docs/api/java/io/ByteArrayOutputStream.html 25 ByteBuffer - https://docs.oracle.com/javase/7/docs/api/java/nio/ByteBuffer.html
21
1.6. Java Native Interface (JNI)
Există anumite situații în care implementarea unor anumite porțiuni din aplicație în
C/C++ oferă programatorului acces la anumite detalii de implementare și posibilități mai
avansate de optimizare. În Java există posibilitatea utilizării porțiunilor de cod nativ prin
intermediul Java Native Interface26.
În aplicația curentă am considerat că următoarele componente ar putea beneficia de
pe urma implementării în C/C++:
Clientul TCP
AudioRecorder – responsabil cu preluarea datelor de la microfon
AudioPlayer – responsabil cu redarea datelor audio
Codecul G.711 – comprimarea datelor audio
În subcapitolele următoare am descris implementările componentelor iar la final, în
sectiunea de concluzii, vom vedea daca a meritat creșterea complexitații aplicației și în ce
masură.
1.6.1. Clientul TCP
Pentru această aplicație am ales utilizarea unui canal de comunicare TCP în
detrimentul UDP din cauza faptului că rețelele mobile sunt, de regulă, mai instabile și
foarte diversificate ca și parametri de securitate ceea ce ar fi putut face protocolul UDP
inutilizabil în unele cazuri (probleme cu pachetele pierdute sau ajunse în ordine gresită,
restrictii privind utilizarea porturilor etc.).
Implementarea unui client TCP în C/C++ pe platforma Android nu este diferită de
cea a unui client obișnuit pentru sistemul de operare Linux. Comunicarea între clientul
TCP (C/C++) și aplicație (Java) se face prin intermediul unor callbackuri setate la
momentul inițializării clientului.
Am optat pentru folosirea în mod blocant a socketului, ajutat de două threaduri –
unul dedicat operațiilor recv și celălalt pentru send. Threadul pentru send are la dispoziție o
coadă de așteptare din care extrage datele ce trebuiesc trimise pe rețea. Datele sunt
introduse în această coadă prin apelarea funcției Send a clasei CTcpClient (pachetele nu
sunt trimise imediat pentru a evita blocarea apelantului pe durata trimiterii).
26 Java Native Interface -
http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/intro.html#wp725
22
1.6.2. OpenSL ES pe platforma Android
OpenSL ES este o interfața de programare audio (în C) standardizata care oferă
performanță ridicată și latență scăzută pentru a accesa funcționalitățile audio ale
dispozitivelor mobile în cadrul aplicațiilor native27.
Funcționalitățile oferite de OpenSL ES sunt disponibile pe platforma Android
începând cu versiunea 2.3 și sunt similare cu cele oferite de interfețele de programare
(scrise în Java)28:
android.Media.MediaPlayer29;
android.Media.MediaRecorder30.
În subcapitolele următoare vom vedea cum pot fi folosite funcționalitățile de
înregistrare și redare de sunet din OpenSL ES și ce eventuale beneficii ar putea aduce
comparativ cu implementările lor disponibile deja în Java.
Un lucru important de reținut este că obiectele OpenSL ES sunt accesibile doar prin
intermediul interfețelor de tipul SLObjectItf. Obiectele OpenSL ES trebuiesc distruse
(Secțiunea de cod 9) în ordinea inversă creării astfel încât să nu fie distruse obiecte încă
utilizate. Android OpenSL ES nu oferă niciun mecanism de detecție a utilizării incorecte a
interfețelor obiectelor și se poate ajunge în unele cazuri ca aplicația sa aiba un
comportament nedefinit sau chiar să înceteze să mai funcționeze31. Ultimul obiect distrus
este obiectul engine.
În Secțiunea de cod 8 avem un exemplu de creare și inițializare a obiectului engine:
slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL); (*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE); (*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineItf);
Secțiunea de cod 8: Crearea și inițializarea obiectului engine
Variabila engineObject este de tipul SLObjectItf iar variabila engineItf este de tipul
SLEngineItf. Crearea altor obiecte se face ulterior prin intermediul interfeței către obiectul
engine (engineItf).
(*engineObject)->Destroy(engineObject); engineObject = NULL; engineItf = NULL;
Secțiunea de cod 9: Distrugerea unui obiect și invalidarea referințelor
27 OpenSL ES - https://www.khronos.org/opensles/ 28 OpenSL ES pe platforma Android - https://developer.android.com/ndk/guides/audio/opensl-for-
android.htm 29 MediaPlayer - https://developer.android.com/reference/android/media/MediaPlayer.html 30 MediaRecorder - https://developer.android.com/reference/android/media/MediaRecorder.html 31 Distrugerea obiectelor OpenSL ES - https://developer.android.com/ndk/guides/audio/opensl-prog-
notes.html#destroy
23
Având în vedere că manipularea obiectelor OpenSL ES este destul de greoaie în cod
și duce ușor la confuzii am hotărât încapsularea detaliilor de implementare în două clase cu
denumiri sugestive:
CAudioRecorder – preluarea datelor de la microfon;
CAudioPlayer – redarea datelor.
1.6.3. Preluarea datelor de la microfon și redarea acestora
Implementarea în C/C++ prin intermediul OpenSL ES ne dă posibilitatea de a
controla managementul bufferelor ce urmează a fi populate cu date de la microfon. Atât
dimensiunea bufferelor cât și numărul lor poate afecta latența sunetului astfel că trebuie
incercate diferite setări până se ajunge la niște valori convenabile.
Implementarea cozii pusă la dispoziție de platofrma Android permite setarea unui
callback care este apelat după ce un buffer a fost utilizat ceea ce ne permite sa-l procesăm
cu ușurință. În cazul de față procesarea bufferului constă în codarea acestuia cu codecul
G.711 și introducerea sa într-o coadă de așteptare din care va ajunge prin JNI într-un
callback din Java. Am luat decizia implementării cozii de așteptare în cazul datelor audio
înregistrate deoarece codul din interiorul callbackului trebuie să fie executat cât mai rapid
și cu o durată cât mai predictibilă altfel vor apărea efecte neplăcute cum ar fi întârzieri sau
întreruperi în procesul de înregistrare32, utilizând coada de așteptare datele vor fi
consumate prin intermediul unui alt thread eliminând astfel pauza necesară consumării
bufferelor.
După ce sunt înregistrate datele sunt trimise printr-un callback din Java către server
apoi serverul trimite mai departe datele către destinatar. Pe partea receptorului datele sunt
trimise prin JNI către clasa CAudioPlayer spre a fi redate. Similar ca în cazul înregistrării
datelor avem acces la o coadă în care sunt introduse datele ce urmează a fi redate cu
mentiunea ca în cazul nostru înainte de introducerea datelor în coadă este necasară
decodarea lor utilizând codecul G.711.
1.6.4. Comprimarea datelor audio
În timpul unei convorbiri audio se generează un trafic foarte mare pe rețea datorită
cantității mari de date audio ce trebuiesc transmise între interlocutori. Acest trafic se
reflectă într-un consum ridicat de resurse (implicit crește și consumul energetic) și în cazul
folosirii unei conexiuni limitate la internet factura clientului poate sa crească.
32 Callbackuri - https://developer.android.com/ndk/guides/audio/opensl-prog-notes.html#callbacks
24
Pentru a rezolva această problemă în cazul sistemelor de comunicație tradiționale s-a
recurs la utilizarea unor codecuri, capabile sa comprime datele audio și chiar să crească
gradul de calitate al convorbirii (prin eliminarea zgomotelor) dar trebuie luată în
considerare și viteza de codare/decodare a codecului astfel încât implementarea lui să fie
benefică utilizatorului și din punctul de vedere al resurselor computaționale utilizate.
Pentru această aplicație am decis utilizarea codecului G.71133 varianta cu compresie
A-Law, fiind în acest moment codecul standard utilizat în Europa pentru convorbirile
telefonice34 și pentru că oferă viteză mare de codare/decodare. Un alt motiv important
pentru care am ales acest codec este disponibilitatea sa spre implementare inca din 1972.
Acest codec mai este regăsit și sub numele de „Pulse Modulation Code of voice
frequencies”.34
Metoda de compresie A-Law este descrisă de Ecuația 1, unde A este numit
parametru de compresie și are valoarea 87.6 în Europa33, și 𝑥 este reprezentarea
normalizată a valorii ce urmează a fi comprimată.
𝐹(𝑥) =
{
𝐴 ∗ |𝑥|
1 + ln(𝐴), 0 ≤ |𝑥| <
1
𝐴𝑠𝑒𝑚𝑛(𝑥) ∗ (1 + ln(𝐴 ∗ |𝑥|))
1 + ln(𝐴),
1
𝐴≤ |𝑥| ≤ 1
Ecuația 1: Ecuația de compresie A-Law
În Tabelul 1 poate fi observat tabelul de codare A-Law. Prima coloană conține datele
de intrare în format liniar pe 13 biți (fiind complementul față de 235 pe 13 biți) și a doua
coloană conține datele codate pe 8 biți. Biții de pe pozițiile marcate cu X vor fi ignorați.
Date de intrare Date codate cu A-Law
S 0 0 0 0 0 0 0 A B C D X S 0 0 0 A B C D
S 0 0 0 0 0 0 1 A B C D X S 0 0 1 A B C D
S 0 0 0 0 0 1 A B C D X X S 0 1 0 A B C D
S 0 0 0 0 1 A B C D X X X S 0 1 1 A B C D
S 0 0 0 1 A B C D X X X X S 1 0 0 A B C D
S 0 0 1 A B C D X X X X X S 1 0 1 A B C D
S 0 1 A B C D X X X X X X S 1 1 0 A B C D
S 1 A B C D X X X X X X X S 1 1 1 A B C D
Tabelul 1: Codare A-Law
33 Codec G.711 - http://www.en.voipforo.com/codec/codecs-g711-alaw.php 34 Detalii despre utilizarea codecului G.711 - https://en.wikipedia.org/wiki/G.711 35 Complement față de 2 - https://en.wikipedia.org/wiki/Two%27s_complement
25
Implementarea procesului de codare conform datelor din Tabelul 1 este destul de simplă:
inline static void encode(const short * src, int len, unsigned char * dst) { short pcm, exponent, mask, mantissa; unsigned char sign; for (int i = 0; i < len; ++i) { pcm = src[i]; sign = (pcm & 0x8000) >> 8; if (sign != 0) { pcm = -pcm; } if (pcm > MAX) { pcm = MAX; } // extrage exponentul exponent = 7; mask = 0x4000; while (((pcm & mask) == 0) && (exponent > 0)) { --exponent; mask >>= 1; } // extrage mantisa if (exponent == 0) { mantissa = pcm >> 4; } else { mantissa = pcm >> ((exponent + 3) & 0x0F); } // compune valoarea alaw unsigned char alaw = (unsigned char)(sign | exponent << 4 | mantissa); dst[i] = alaw ^ 0xD5; } }
Secțiunea de cod 10: Implementarea codarii G.711 A-Law în C
În Secțiunea de cod 6 se pot observa cei trei pași principali în procesul de codare. La pasul
de extragere a exponentului este căutat primul bit setat pe 1 dupa bitul de semn. La găsirea
primului bit ne oprim și setăm exponentul ca fiind poziția bitului respectiv (numărând
descrescător, bitul de semn având poziția 8, următorul bit la dreapta poziția 7 și tot așa).
Mantisa este reprezentată de urmatorii 4 biți de după bitul care a dat exponentul. Pentru a
extrage mantisa mutăm toți biții la dreapta cu (exponent + 3) poziții și extragem primii 4
biți. În cazul în care exponentul este 0 mutăm toți biții la dreapta cu 4 poziții.
26
Procesul de decodare A-Law36 este descris de Ecuația 2, unde A este parametrul de
compresie (în Europa A = 87.6), iar y este reprezentarea normalizată e valorii ce urmează a
fi decodată.
𝐹−1(𝑦) = 𝑠𝑒𝑚𝑛(𝑦) ∗
{
|𝑦| ∗ (1 + ln(𝐴))
𝐴, |𝑦| <
1
1 + ln(𝐴)
exp(|𝑦| ∗ (1 + ln(𝐴)) − 1)
𝐴,
1
1 + ln(𝐴)≤ |𝑦| < 1
Ecuația 2: Ecuația de decompresie A-Law
În Tabelul 2 poate fi observat procesul de decodare A-Law.
Date de intrare codate Date de iesire liniare
S 0 0 0 A B C D S 0 0 0 0 0 0 0 A B C D 1
S 0 0 1 A B C D S 0 0 0 0 0 0 1 A B C D 1
S 0 1 0 A B C D S 0 0 0 0 0 1 A B C D 1 0
S 0 1 1 A B C D S 0 0 0 0 1 A B C D 1 0 0
S 1 0 0 A B C D S 0 0 0 1 A B C D 1 0 0 0
S 1 0 1 A B C D S 0 0 1 A B C D 1 0 0 0 0
S 1 1 0 A B C D S 0 1 A B C D 1 0 0 0 0 0
S 1 1 1 A B C D S 1 A B C D 1 0 0 0 0 0 0
Tabelul 2: Decodare A-Law
Implementarea procesului de decodare se rezumă doar la a extrage exponentul și mantisa
setate la pasul de codare, setarea primului bit de dupa mantisa pe 1 și așezarea în ordine
într-o variabilă de tip short.
36 Procesul de decodare A-Law - https://en.wikipedia.org/wiki/A-law_algorithm
27
inline static void decode(const unsigned char * src, int len, short * dst) { unsigned char alaw, sign; short exponent, data; for (int i = 0; i < len; ++i) { alaw = src[i] ^ 0xD5; sign = alaw & 0x80; exponent = (alaw & 0x70) >> 4; data = alaw & 0x0f; data <<= 4; data += 8; if (exponent != 0) { data += 0x100; } if (exponent > 1) { data <<= (exponent - 1); } if (sign == 0) { dst[i] = data; } else { dst[i] = -data; } } }
Secțiunea de cod 11: Implementarea procesului de decodare G.711 A-Law în C
Se poate observa că procesul de decodare expandează o valoare de tip byte la o
valoare de tip short. Asta înseamnă că procesul de decodare ar putea fi redus la o căutare
într-o tabelă cu valori precalculate, evitând astfel efectuarea inutilă a unor calcule. În urma
optimizării pasul de decodare devine:
inline static void decode_optimized(const unsigned char * src, int len, short * dst) { for (int i = 0; i < len; ++i) { dst[i] = cached_alaw_to_linear[src[i]]; } }
Secțiunea de cod 12: Procesul de codare optimizat cu tabelă de valori precalculate
Unde cached_alaw_to_linear reprezintă o tabelă cu 256 de valori de tip short precalculate.
Având în vedere că operațiile efectuate pentru a coda/decoda datele sunt
preponderent operații pe biți am suspectat faptul că implementarea codecului în C ar putea
reduce timpul de executie astfel că am conceput o serie de teste pentru a măsura numărul
de operații (de codare/decodare) executate în fiecare secundă în cazurile în care codecul
este implementat în Java sau în C/C++.
Codul testat este similar pentru cele două limbaje cu mențiunea că versiunea pentru
Java a fost incapsulată într-o clasă cu metode statice.
28
0
200000
400000
600000
800000
1000000
Java C/C++ (JNI)
Normal
Graficul 1: Număr de codari pe secundă
0
200000
400000
600000
800000
1000000
1200000
1400000
1600000
Java C/C++ (JNI)
Normal
Optimizat cu
valori
precalculate
Graficul 2: Număr de decodari pe secundă
Conform rezultatelor obținute observăm un caștig de performanță de peste 100%
datorat implementării în C/C++. Testele rulate nu au luat în calcul costul apelurilor JNI dar
acesta este irelevant având în vedere cp apelurile către codec vor fi facute din clasele
CAudioRecorder și CAudioPlayer implementate de asemenea în C/C++. Mașina gazdă pe
care au fost rulate testele este un telefon emulat37.
1.6.5. Concluzii
În urma implementării și testarii componentelor descrise în subcapitolele anterioare
am văzut că implementarea unor componente intr-un limbaj de nivel scăzut și apelarea lor
prin JNI poate fi benefică în cazul în care avem nevoie de un plus de performanță pentru
anumite metode utilizate frecvent (de exemplu codecul G.711), dar este foarte posibil ca
beneficiile obținute sa nu merite efortul creșterii complexitații aplicației.
În cazul claselor CAudioRecorder și CAudioPlayer obținem, prin implementarea lor
în C/C++, acces la detaliile de implementare ceea ce ne permite să configuram
componentele astfel incât să obtinem o întârziere minimă a sunetului dar diferența dintre
clasele din Java puse la dispoziție de platforma Android și implementarea curentă în
37 Specificații telefon emulat - Intel Atom (x86), 1 GB RAM, sistem de operare Android 5.0.1. Mașina
gazdă a emulatorului dispune de un procesor Intel i7 4790k 4GHz si 16 GB RAM.
29
C/C++ nu este semnificativă, în schimb complexitatea aplicației creșste destul de mult
(ingreunând mult și procesul de debug).
În concluzie implementarea componentelor CAudioRecorder, CAudioPlayer și a
codecului G.711 în C/C++ aduce beneficii aplicației curente (având în vedere ca apelurile
către metodele de codare/decodare a codecului pot fi optimizate de compilatorul C/C++,
nefiind astfel nevoie de apeluri costisitoare prin JNI).
30
Capitolul 2: Dezvoltarea aplicației server
Aplicația server a unui serviciu de comunicare în timp real trebuie să facă față cu
ușurință unui număr mare de clienți conectați simultan și să onoreze cererile acestora cât
mai repede pentru a evita apariția unor întârzieri neplăcute în timpul comunicării (de
exemplu comunicare audio) dintre doi clienți.
În ideea de a îndeplini acest obiectiv am recurs la implementarea serverului în C/C++
pe sistemul de operare Linux pentru a avea acces la cât mai multe detalii de implementare
și optimizare.
2.1. Arhitectura generala a aplicației
Figura 6: Arhitectura generala a aplicației server
31
În Figura 4 sistemul asincron de lansare și executare a interogarilor este reprezentat
sumar. Mai multe detalii despre acest sistem se află în subcapitolul 2.2.3.
Serverul HTTP extern este folosit pentru a servi clienților imaginile de profil ale
utilizatorilor în funcție de un identificator numeric (mai multe detalii în subcapitolul 2.2.4).
2.2. Detalii de implementare
Serverul are doua componente: una implementată în C/C++ și cealalta (serverul
HTTP extern) implementată în Javascript pentru platforma node.js. În subcapitolele
următoare vor fi prezentate cele mai importante componente ale aplicației server, detalii
legate de implementările lor și eventuale imbunatațiri aduse.
2.2.1. Serverul TCP
Există mai multe modalități de a implementa un server TCP. Dacă luăm în
considerare modul de utilizare a sockeților avem urmoatoarea clasificare:
sincron;
asincron.
În subcapitolele următoare vom vedea diferențele între sincron și asincron, avantajele și
dezavantajele fiecăruia și modalități de monitorizare a sockeților pentru a putea decide
modelul de server care ar performa cel mai bine în cazul aplicației curente.
2.2.1.1. Sincron versus asincron
Utilizarea sockeților în mod sincron presupune ca threadul care efectuează un apel
către o funcție (de exemplu recv) ce utilizează un socket anume să fie blocat pana când are
loc un eveniment pentru acel socket. În cazul funcției recv threadul va fi blocat până măcar
un octet este citit în buffer sau are loc un eveniment (eroare de exemplu).
Modul asincron nu blochează threadul ci returnează imediat fie -1 fie informațiile
disponibile. În cazul în care este returnat -1 în variabila errno va fi pus codul de eroare. În
plus față de codurile obișnuite de eroare valabile pentru socketi mai avem și EAGAIN sau
EWOULDBLOCK care înseamnă că informațiile sau socketul nu sunt disponibile și ar fi
trebuit blocată execuția. Setarea unui socket în modul neblocant se face astfel (unde sfd
este descriptorul):
32
int flags = fcntl (sfd, F_GETFL, 0); if (flags == -1) { return; } flags |= O_NONBLOCK; if (fcntl (sfd, F_SETFL, flags) == -1) { return; }
Secțiunea de cod 13: Setarea unui descriptor în modul neblocant
Este evident că modul asincron deschide noi posibilități de procesare concurentă a
evenimentelor sockeților dar ridică alte probleme descrise în subcapitolele următoare.
2.2.1.2. Tratarea concurentă a clienților
Având în vedere natura aplicației este imperios necesară tratarea în mod concurent a
clienților. Cele mai populare modele de server capabil să trateze clienții în mod concurent
sunt:
Cate un thread pe conexiune;
Cate un proces copil pe conexiune(fork38);
Prethreaded – threadurile sunt create în prealabil;
Preforked – procesele copil sunt create în prealabil.
Aceste patru modele sunt folosite frecvent în practică și sunt eficiente în cazul în care nu
este necesară monitorizarea unui număr mare de sockeți. Ultimele două modele reprezintă
îmbunătățiri ale primelor două întrucât este eliminat costul creării unui thread/proces copil
în momentul stabilirii unei noi conexiuni. În ciuda imbunătățirilor aduse de ultimele două
modele niciunul din cele patru nu va scala bine pentru numere mari (peste 1000) de socketi
din cauză că resursele sistemului ar fi folosite în mare parte pentru managementul
threadurilor și al proceselor copil create iar procesarea propriu-zisă ar fi întârziată
semnificativ, ceea ce nu este acceptabil într-o aplicație care oferă comunicare în timp real.
Pentru a combate această problemă au fost introduse diverse sisteme de
monitorizare a socketilor. În Linux cele mai populare sisteme sunt:
select39;
poll40;
38 fork() - http://linux.die.net/man/2/fork 39 Documentație select() - http://man7.org/linux/man-pages/man2/select.2.html 40 Documentație poll() - http://man7.org/linux/man-pages/man2/poll.2.html
33
epoll41.
Mecanismul select permite monitorizarea mai multor sockeți în același timp. Apelul către
select blochează până când cel puțin unul dintre sockeții monitorizați este „pregatit” pentru
a fi procesat sau apelul este întrerup de un semnal sau expiră timpul alocat apelului. O
limitare destul de importantă a acestui sistem este faptul că este posibilă monitorizarea
descriptorilor mai mici decat FD_SETSIZE (valoare implicita 1024).
În cele mai multe cazuri mecanismul select va face față fără probleme însă în cazul
de față limita de 1024 de descriptori limitează drastic capacitatea serverului. Chiar daca ar
fi marită valoarea FD_SETSIZE tot ar există probleme din cauza modului în care este
definită structura fd_set:
typedef struct { long int fds_bits[32]; } fd_set;
Secțiunea de cod 14: Definiția structurii fd_set
Variabila fds_bits nu poate ține evidența a mai mult de 1024 de descriptori.
În cazul în care această limită este prea mică este preferată utilizarea mecanismului
poll. Acest mecanism nu prezintă nicio limită referitor la numărul de descriptori ce pot fi
monitorizați, sarcina alocării listei de descriptori revenind utilizatorului, însă nu este foarte
portabil.
Mecanismul epoll este cel mai recent mecanism de acest gen introdus în Linux.
Comportamentul său este similar cu cel al mecanismului poll cu mențiunea că poate
funcționa atât în modul edge-triggered cât și în modul level-triggered. Pentru a înțelege
diferența între cele două moduri voi folosi un exemplu similar cu cel dat de dezvoltator:
1. Adaug un descriptor într-o instanță epoll spre a fi monitorizat;
2. Descriptorul introdus este gata pentru citire (200 octeți);
3. Descriptorul este returnat de un apel la funcția epoll_wait;
4. Citesc 100 de octeți;
5. Apelez epoll_wait.
În cazul edge-triggered apelul spre epoll_wait de la pasul 5 va bloca, în ciuda faptului că
există inca 100 de octeți pentru acel descriptor care ar putea fi citiți. Acest eveniment ar
putea duce la blocaje majore pe partea clientului care ar putea astepta un raspuns de la
server (care este blocat desi are datele necesare disponibile).
41 Documentație epoll() - http://man7.org/linux/man-pages/man7/epoll.7.html
34
În cazul utilizării în modul level-triggered epoll se va comporta similar cu poll.
Este de reținut faptul că apelul epoll_wait va returna doar descriptorii care sunt gata de
procesare ceea ce reprezintă o imbunatațire majoră în cazul unui server care are un număr
mare de conexiuni deschise dar majoritatea au o activitate redusă.
2.2.1.3. Implementare cu epoll
Pentru a simplifica structura codului am încapsulat în clasa CEpoll funcționalitățile
oferite de mecanismul epoll. Tratarea evenimentelor descriptorilor se face printr-un obiect
ce implementează interfață IEpollEventsListener (Secțiunea de cod 15).
class IEpollEventsListener { public: virtual void OnReadReady(const epoll_event & event) = 0; virtual void OnReadyToWrite(const epoll_event & event) = 0; virtual void OnClose(const epoll_event & event) = 0; };
Secțiunea de cod 15: Declarația interfeței IEpollEventsListener
Bucla principală de tratare a evenimentelor cu ajutorul mecanismului epoll arată astfel:
inline void CEpoll::Worker() { epoll_event * events = static_cast<epoll_event *>(calloc(16, sizeof(epoll_event))); epoll_event * current_event = nullptr; while (_running) { int n = epoll_wait(_epoll_fd, events, 16, 0); for (int i = 0; i < n; ++i) { current_event = &events[i]; if (_eventsListener != nullptr) { if (current_event->events & EPOLLIN) { _eventsListener->OnReadReady(*current_event); } if (current_event->events & EPOLLOUT) { _eventsListener->OnReadyToWrite(*current_event); } // ... } } } delete [] events; }
Secțiunea de cod 16: Bucla principala de tratare a evenimentelor
35
2.2.1.4. Recepționarea datelor în mod asincron
Recepționarea datelor în mod asincron ridica o problema logistica – datele pot fi
recepționate partial, fragmentate sau integral.
Ca soluție la această problemă am implementat clasa CIncomingPacket care are
capacitatea de a reconstrui un pachet de date din fragmentele sale. Fiecare utilizator
conectat are asociată o instanță a acestei clase denumită „pending incoming packet”.
Această instanță retine datele recepționate incomplet până în prezent. Cand datele sunt
recepționate complet instanța acestei clase este trimisă sistemului de procesare a cererilor
și instanța „pending incoming packet” este reinitializata astfel încât datele recepționate
ulterior sa fie și ele reconstruite corect în pachete.
Reconstrucția pachetelor este posibilă pentru că stim dimensiunea inițială a
pachetului (vezi subcapitolul 1.5). Pe masură ce primim fragmente dintr-un pachet este
actualizat un indicator care ține minte poziția curentă în buffer. Când indicatorul ajunge la
dimensiunea cunoscută a pachetului înseamnă că pachetul este complet și că poate fi
procesat.
2.2.1.5. Procesarea cererilor în mod asincron
Pentru procesarea în mod asincron a cererilor am recurs la implementarea unui
thread pool care utilizează o coadă blocantă de așteptare. Cand o cerere este introdusa în
coada de așteptare un thread este notificat pentru a o procesa.
Având în vedere natura aplicației este posibil să apară situații de desincronizare a
accesului asupra aceleași instanțe a unui obiect astfel că a fost necesară sincronizarea
anumitor parți. Pentru a minimiza impactul negativ asupra performanței a sincronizării am
recurs la utilizarea obiectelor de tipul std::atomic<T>42 cât de mult posibil.
Obiectele de tipul std::atomic<T> pot încapsula alte tipuri de date și oferă intr-un
mod neblocant acces la o variabilă în cadrul programarii concurente.
2.2.1.6. Expedierea datelor în mod asincron
Similar recepționării datelor există posibilitatea ca datele să nu poată fi expediate
intr-un singur apel spre funcția send ceea ce impune implementarea unui sistem similar cu
cel din subcapitolul 2.2.1.4 pentru tratarea acestor cazuri.
42 Biblioteca pentru operații atomice - http://en.cppreference.com/w/cpp/atomic
36
Spre deosebire de cazul recepționării datelor aici trebuie să spunem instanței de epoll
că dorim să primim notificare în cazul în care un socket devine pregătit pentru expedierea
datelor:
epoll_event event; event.data.ptr = this; event.events = EPOLLIN | EPOLLOUT | EPOLLET; epoll_ctl(_epollFd, EPOLL_CTL_MOD, _fd, &event);
Secțiunea de cod 17: Semnalarea instanței de epoll ca ne intereseaza evenimentul EPOLL
În rest tratarea expedierii datelor incomplete se face similar ca în cazul recepționării
(ținând evidența datelor transmise deja prin intermediul unui indicator).
2.2.1.7. Concluzii
Având în vedere cele prezentate anterior putem concluziona că în cazul aplicației
curente mecanismul epoll reprezintă cea mai bună opțiune de tratare în mod concurent a
unui număr mare de clienți într-un mod cât mai eficient.
Un dezavantaj al utilizării mecanismului epoll este lipsa portabilității întrucât acesta
este un mecanism specific platformei Linux.
2.2.2. Baza de date persistentă
Pentru a ține evidența utilizatorilor, a conversațiilor și a istoricului apelurilor este
necesară reținerea unor anumite informații într-o bază de date persistentă. Există mai multe
soluții disponibile dar pentru această aplicație am ales sa folosesc MySQL pentru că este
un produs matur care dispune de un grad ridicat de compatibilitate cu diverse platforme și
medii de dezvoltare.
2.2.2.1. Execuția clasică a interogărilor
Comunicarea între aplicația server și baza de date MySQL se faca prin intermediul
interfeței de programare C++ pusp la dispoziție de dezvoltatori. Executia interogărilor prin
intermediul acestei interfețe are patru pași:
Crearea conexiunii;
Crearea interogării;
Execuția interogării;
Eliberarea resurselor.
37
sql::Driver * dr = get_driver_instance(); sql::Connection * con = dr->connect("tcp://127.0.0.1:3306", "user", "parola"); con->setSchema("licenta"); sql::PreparedStatement * stmt = con->PrepareStatement("SELECT * FROM users"); sql::ResultSet * res = stmt->executeQuery(); delete res; delete stmt; con->close(); delete con;
Secțiunea de cod 18: Exemplu de lansare și executie clasica a unei interogări[7]
Această modalitate de execuție a interogărilor este simplă dar are un dezavantaj
major în cadrul aplicațiilor asincrone – blochează threadul curent până la sosirea
rezultatelor interogării. Acest lucru poate duce la întârzieri însemnate în procesarea
cererilor și implicit reducerea drastică a capacitații serverului.
2.2.2.2. Lansarea asincrona a interogărilor
Din păcate această interfață de programare nu permite comunicarea asincronă cu
baza de date astfel că am creat, peste interfață de programare pusă la dispoziție, un sistem
de execuție al interogărilor care nu necesită blocarea threadului care lansează interogarea.
Figura 7: Diagrama sistemului de interogări
Acest sistem dispune de o coadă de așteptare în care sunt introduse interogările ce
urmează a fi efectuate și de mai multe threaduri responsabile cu procesarea interogărilor
din coadă. Fiecare thread are la dispoziție o conexiune proprie cu baza de date. Pasul de
crearea a unei conexiuni cu baza de date este destul de costisitor și de aceea am hotărât
reciclarea conexiunilor prin intermediul unui object pool cu dimensiunea fixă. Procesarea
38
unei interogări are două etape:
Pregatirea interogării;
Execuția interogării.
Programatorul poate atribui cate un callback pentru fiecare etapă astfel incâ să poată
acționa corespunzător (de exemplu dacă o interogare necesită parametri dinamici aceștia
vor fi setați în callbackul corespunzător pregătirii interogării).
Dupa executia interogării este apelat cel de-al doilea callback, având ca parametru
rezultatele interogării.
2.2.2.3. Interogări irelevante
Dat fiind faptul că serverul este asincron este posibil ca o interogare întârziată să
devină irelevantă. De exemplu la momentul lansării interogării se doresc anumite
informații despre Utilizatorul A și programatorul construiește callbackurile în consecință
dar la momentul finalizării execuției interogării instanța clasei CUser corespunzătoare cu
Utilizatorul A este asociata cu Utilizatorul B (din cauza sistemului de reciclare a obiectelor
CUser). Pentru a evita astfel de probleme utilizatorul trebuie sa verifice în callbackuri
relevanța interogării.
În cazul în care interogarea devine irelevantă înainte de execuția ei (la pasul de
pregătire a interogării) programatorul are posibilitatea anulării execuției interogării salvând
astfel timp și resurse.
int currentUserId = GetId(); db.EnqueueQuery(this, "SELECT * FROM history WHERE caller = ?", [this, currentUserId](sql::PreparedStatement * stmt) { if (GetId() != currentUserId) { return false; } stmt->setInt(1, currentUserId); return true; }, [this, currentUserId](sql::ResultSet * res) { if (GetId() != currentUserId) { return; } //... });
Secțiunea de cod 19: Exemplu lansare interogare
2.2.2.4. Concluzii
Pentru a decide dacă utilizarea sistemului de lansare asincronă interogărilor a adus
plusul de performanță dorit am rulat un test pe parcursul căruia am măsurat durata de
39
execuție a 10, 50, 100, 1000 de interogări atât cu sistemul clasic cât și cu cel propus,
asincron. Pe axa X se află numărul de interogări iar pe axa Y se află timpul măsurat în
secunde. Sistemul asincron a fost initializat cu 4 threaduri. Fiecare interogare simulează o
așteptare de o secundă pentru a simula niște interogări costisitoare.
0
200
400
600
800
1000
1200
1400
10 50 100 1000
Clasic
Asincron
Graficul 3: Număr de interogări lansate și executate pe secundă
Conform rezultatelor (Graficul 3) obținute utilizarea sistemului de lansare asincronă
a interogărilor scade timpul de execuție semnificativ comparativ cu lansarea și execuția
clasică a interogărilor. Această eliminare a timpului de așteptare în threadul care lansează
interogarea sporește substanțial capacitatea serverului fără a crește foarte mult
complexitatea aplicației. Putem spune ca sistemul de lansare și execuție asincron aduce
rezultatul așteptat cu un minim de efort.
2.2.3. Server HTTP pentru imaginile de profil
Pentru implementarea serverului HTTP responsabil cu imaginile de profil ale
utilizatorilor am recurs la platforma node.js43 fiind ușor de configurat și rulat în aproape
orice mediu cu un impact minim asupra resurselor sistemului. Un server HTTP care oferă
utilizatorului imagini ar putea genera un consum ridicat de bandă de internet dar platforma
node.js se bucură de suport din partea celor mai mari furnizori de servicii cloud astfel că
portarea acestui server în cloud nu ar ridica nicio problemă.
Acest server are douî roluri:
Oferî utilizatorului imagini de profil în funcție de un identificator numeric unic
Actualizează imaginea de profil a utilizatorului curent.
Pentru a îndeplini primul rol serverul se comportă ca un server HTTP pentru fișiere statice
– caută pe disc poza corespunzătoare identificatorului numeric iar dacă nu o gasește trimite
utilizatorului o poză de profil implicită.
43 Node.js - https://nodejs.org/en/about/
40
Node.js pune la dispoziția dezvoltatorilor tot felul de extensii care simplifică mult
procesul de dezvoltare al aplicațiilor. Una dintre cele mai populare extensii este
Express.js44. Această extensie permite creare unui server HTTP și rutarea rapidă în funcție
de URL. Mai jos este un exemplu[10] de aplicație scrisă cu ajutorul Express.js care va crea
un server HTTP care intoare un mesaj către client:
var express = require('express'); var app = express(); app.get('/', function (req, res) { res.send('Salut!'); }); app.listen(8080, function () { console.log('Serverul asteapta la portul 8080!'); });
Secțiunea de cod 20: Exemplu de aplicație scrisă cu Express.js
Cel de-al doilea rol presupune autentificarea utilizatorului pentru a preveni situatia în
care un utilizator malițios modifică abuziv imaginea de profil al altui utilizator. Aceasta
autentificare se face prin intermediul bazei de date persistentă, validând informațiile
primite de la utilizatorul ce dorește actualizarea unei imagini.
44 Express.js - http://expressjs.com/
41
Concluzii generale
Scopul acestei lucrari a fost acela de a identifica provocările ridicate de
implementarea unei aplicații de mesagerie vocală și textuală și de a găsi soluții optime
pentru rezolvarea lor.
Implementarea clientului pentru platforma Android a devenit usoară din momentul
ințelegerii arhitecturii unei asemenea aplicații. Partea de implementare nativă a anumitor
componente nu a ridicat probleme majore deși suportul acestora pe platforma Android este
încă marcat ca fiind experimental. Am văzut cum implementarea unui codec relativ simplu
ca și complexitate reduce la jumătate cantitatea de date audio generată în timpul unei
convorbiri și de asemenea am văzut cum implementarea nativă și optimizarea cu tabela de
valori precalculate a dus la un plus de performanța însemnat.
Implementarea aplicației server a ridicat mai mult probleme logistice. Fiind un server
a cărui funcționare depinde de mai multe componente ce funcționează asincron a fost
nevoie de delimitarea foarte clară a zonei de activitate a fiecărei componente astfel încât
ele să lucreze eficient impreună. Am văzut implementarea unui server TCP asincron
utilizând mecanismul de monitorizare epoll și problemele ridicate în urma utilizării
asincrone dar și rezolvările lor. În capitolul 2 am văzut de asemenea avantajele majore
aduse de implementarea asincronă și utilizarea unui object pool în cadrul componentei de
lansare și executare a interogărilor dar și eventuale probleme care ar putea surveni.
42
Bibliografie
[1] Google Inc., Android Developers Documentation -
https://developer.android.com/reference/packages.html
[2] Oracle Corporation, Java Native Interface Specification -
http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/jniTOC.html
[3] Google Inc., OpenSL ES for Android -
https://developer.android.com/ndk/guides/audio/opensl-for-android.html
[4] Intel Corporation, 9-Patch Images for Android - https://software.intel.com/en-
us/xdk/articles/android-splash-screens-using-nine-patch-png
[5] Linux Kernel Organization, man-pages - https://www.kernel.org/doc/man-pages/
[6] Robert Love, Septembrie 2007, „The Event Pool Interface” din „Linux System
Programming”, O'Reilly Media Inc. -
https://www.safaribooksonline.com/library/view/linux-system-
programming/0596009585/ch04s02.html
[7] Oracle Corporation, MySQL Connector/C++ Developer Guide -
https://dev.mysql.com/doc/connector-cpp/en/
[8] cppreference.com, C++ Reference - http://en.cppreference.com/w/cpp
[9] Node.js Foundation, Node.js Docs - https://nodejs.org/en/docs/
[10] Node.js Foundation, Express.js API reference - http://expressjs.com/en/4x/api.html