В этом уроке мы собираемся изучить, как использовать акселерометр, один из многих аппаратных датчиков современных смартфонов, в приложении для Android. Я объясню, что такое акселерометр и почему его можно использовать в своих приложениях для Android.
Вступление
До рассвета смартфонов одним из немногих аппаратных компонентов, с которыми могли взаимодействовать приложения, была клавиатура. Но времена изменились, и взаимодействие с аппаратными компонентами становится все более распространенным.
Использование жестов часто кажется более естественным, чем взаимодействие с пользовательским интерфейсом с помощью мыши и клавиатуры. Это особенно актуально для сенсорных устройств, таких как смартфоны и планшеты. Я считаю, что использование жестов может оживить приложение Android, сделав его более интересным и интересным для пользователя.
В настоящее время довольно много приложений используют акселерометр. Например, посмотрите на эти шаблоны приложений на Envato Market, которые включают в себя игру скоростных гонок и шейкер случайных чисел .
В этом уроке мы будем использовать жест, который вы найдете во многих мобильных приложениях, жест встряхивания. Мы будем использовать жест встряхивания, чтобы случайным образом сгенерировать шесть номеров лотереи и отобразить их на экране, используя симпатичную анимацию.
1. Начало работы
Шаг 1: Настройка проекта
Запустите новый проект Android в вашей любимой среде разработки (интегрированная среда разработки) для разработки под Android. Для этого урока я буду использовать IntelliJ IDEA .
Если ваша IDE поддерживает разработку Android, она создаст для вас Main
класс. Имя этого класса может отличаться в зависимости от того, какую IDE вы используете. Класс Main
играет ключевую роль при запуске вашего приложения. В вашей среде IDE также должен быть создан основной файл макета, который класс Main
использует для создания пользовательского интерфейса приложения.
Поскольку мы собираемся использовать жест встряхивания, рекомендуется заблокировать ориентацию устройства. Это гарантирует, что пользовательский интерфейс приложения не будет постоянно переключаться между книжной и альбомной ориентацией. Откройте файл манифеста проекта и установите для параметра screenOrientation
значение screenOrientation
.
1
2
3
4
5
6
7
8
|
<activity android:name=»com.Lottery.Main»
android:screenOrientation=»portrait»
android:label=»@string/app_name»>
<intent-filter>
<action android:name=»android.intent.action.MAIN» />
<category android:name=»android.intent.category.LAUNCHER» />
</intent-filter>
</activity>
|
Шаг 2: Настройка датчика
Когда наш проект настроен, пришло время испачкать руки и написать немного кода. На данный момент основной класс активности имеет метод onCreate
в котором мы устанавливаем основной макет, вызывая setContentView
как показано ниже.
1
2
3
4
5
6
7
8
9
|
public class Main extends Activity {
/** Called when the activity is first created.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
}
}
|
В зависимости от используемой среды IDE может потребоваться добавить несколько операторов Main.java
в файл Main.java
, в котором находится ваш Main
класс. Большинство IDE вставят эти операторы импорта для вас, но я хочу убедиться, что мы находимся на той же странице, прежде чем мы продолжим. Первая инструкция import, import android.app.Activity
, импортирует класс Activity
а вторая инструкция import android.os.Bundle
, импортирует класс Bundle
. Третий оператор импорта com.example.R
содержит определения ресурсов приложения. Этот оператор импорта будет отличаться от того, который вы видите ниже, так как он зависит от имени вашего пакета.
1
2
3
|
import android.app.Activity;
import android.os.Bundle;
import com.example.R;
|
На следующем шаге мы будем использовать интерфейс SensorEventListener
, который объявлен в Android SDK. Чтобы использовать интерфейс SensorEventListener
, класс активности Main
должен реализовать его, как показано в фрагменте кода ниже. Если вы посмотрите на обновленный класс активности Main
, вы обнаружите, что я использую ключевое слово SensorEventListener
чтобы сообщить компилятору, что класс Main
реализует интерфейс SensorEventListener
.
1
2
3
4
5
6
7
8
9
|
public class Main extends Activity implements SensorEventListener {
/** Called when the activity is first created.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
}
}
|
Чтобы использовать интерфейс SensorEventListener
, вам нужно добавить еще один оператор импорта, как показано ниже. Большинство IDE будут разумно добавлять оператор импорта для вас, так что вам, вероятно, не придется беспокоиться об этом.
1
|
import android.hardware.SensorEventListener;
|
С того момента, как вы обновите реализацию класса Main
как показано выше, вы увидите несколько ошибок. Это неудивительно, поскольку нам нужно два реализующих два обязательных метода интерфейса SensorEventListener
.
Если вы используете IntelliJ IDEA, вам будет предложено добавить эти обязательные методы при нажатии на ошибку. Если вы используете другую IDE, это поведение может отличаться. Давайте добавим два обязательных метода вручную, как показано в фрагменте кода ниже. Обязательно добавьте эти методы в класс Main
и вне метода onCreate
.
1
2
3
4
5
6
7
8
9
|
@Override
public void onSensorChanged(SensorEvent event) {
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
|
Давайте посмотрим на метод onSensorChanged
. Мы будем использовать этот метод для обнаружения тряски. Метод onSensorChanged
вызывается каждый раз, когда встроенный датчик обнаруживает изменение. Этот метод вызывается повторно всякий раз, когда устройство находится в движении. Чтобы использовать классы Sensor
и SensorEvent
, мы добавляем два дополнительных оператора импорта, как показано ниже.
1
2
|
import android.hardware.Sensor;
import android.hardware.SensorEvent;
|
Прежде чем мы реализуем onSensorChanged
, нам нужно объявить две частные переменные в классе Main
, senSensorManager
типа SensorManager
и senAccelerometer
типа Sensor
.
1
2
|
private SensorManager senSensorManager;
private Sensor senAccelerometer;
|
Класс SensorManager
объявлен в android.hardware.SensorManager
. Если вы видите какие-либо ошибки, проверьте еще раз, что класс SensorManager
импортирован.
1
|
import android.hardware.SensorManager;
|
В методе onCreate
мы инициализируем только что объявленные переменные и регистрируем прослушиватель. Посмотрите на обновленную реализацию метода onCreate
.
1
2
3
4
5
6
7
8
9
|
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
senSensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
senAccelerometer = senSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
senSensorManager.registerListener(this, senAccelerometer , SensorManager.SENSOR_DELAY_NORMAL);
}
|
Чтобы инициализировать экземпляр SensorManager
, мы вызываем getSystemService
чтобы извлечь системный экземпляр SensorManager
, который мы, в свою очередь, используем для доступа к системным датчикам. Метод getSystemService
используется для получения ссылки на службу системы путем передачи имени службы. Имея в своем распоряжении диспетчер датчиков, мы получаем ссылку на акселерометр системы, вызывая getDefaultSensor
в диспетчере датчиков и передавая тип интересующего нас датчика. Затем мы регистрируем датчик, используя один из открытых методов SensorManager
, registerListener
. Этот метод принимает три аргумента: контекст действия, датчик и скорость, с которой события датчика доставляются нам.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
public class Main extends Activity implements SensorEventListener {
private SensorManager senSensorManager;
private Sensor senAccelerometer;
/** Called when the activity is first created.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
senSensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
senAccelerometer = senSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
senSensorManager.registerListener(this, senAccelerometer , SensorManager.SENSOR_DELAY_NORMAL);
}
@Override
public void onSensorChanged(SensorEvent sensorEvent) {
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
|
Есть два других метода, которые нам нужно переопределить: onPause
и onResume
. Это методы Main
класса. Рекомендуется отменить регистрацию датчика, когда приложение переходит в спящий режим, и зарегистрировать датчик снова, когда приложение возобновит работу. Посмотрите на фрагменты кода ниже, чтобы понять, как это работает на практике.
1
2
3
4
|
protected void onPause() {
super.onPause();
senSensorManager.unregisterListener(this);
}
|
1
2
3
4
|
protected void onResume() {
super.onResume();
senSensorManager.registerListener(this, senAccelerometer, SensorManager.SENSOR_DELAY_NORMAL);
}
|
Шаг 3: Обнаружение дрожания жеста
Теперь мы можем сосредоточиться на сути приложения. Потребуется немного математики, чтобы понять, когда происходит жест встряхивания. Большая часть логики перейдет в метод onSensorChanged
. Мы начнем с объявления нескольких переменных в нашем Main
классе. Посмотрите на фрагмент кода ниже.
1
2
3
|
private long lastUpdate = 0;
private float last_x, last_y, last_z;
private static final int SHAKE_THRESHOLD = 600;
|
Давайте теперь рассмотрим реализацию метода onSensorChanged
. Мы SensorEvent
ссылку на экземпляр Sensor
с SensorEvent
экземпляра SensorEvent
который передается нам. Как вы можете видеть из фрагмента кода ниже, мы дважды проверяем, что получаем ссылку на правильный тип датчика, акселерометр системы.
1
2
3
4
5
6
7
|
public void onSensorChange(SensorEvent sensorEvent) {
Sensor mySensor = sensorEvent.sensor;
if (mySensor.getType() == Sensor.TYPE_ACCELEROMETER) {
}
}
|
Следующим шагом является извлечение положения устройства в пространстве, по осям x
, y
и z
. Посмотрите на изображение ниже, чтобы лучше понять, о чем я говорю. Ось x
определяет боковое движение, а ось y
определяет вертикальное движение. Ось z
немного сложнее, поскольку она определяет движение в и из плоскости, определяемой осями x
и y
.
Чтобы получить значения каждой оси, мы запрашиваем событие датчика для его значений, как показано ниже. Атрибут values
события представляет собой массив значений с плавающей точкой.
1
2
3
4
5
6
7
8
9
|
public void onSensorChange(SensorEvent sensorEvent) {
Sensor mySensor = sensorEvent.sensor;
if (mySensor.getType() == Sensor.TYPE_ACCELEROMETER) {
float x = sensorEvent.values[0];
float y = sensorEvent.values[1];
float z = sensorEvent.values[2];
}
}
|
Датчики системы невероятно чувствительны. Держа устройство в руке, оно постоянно находится в движении, независимо от того, насколько устойчива ваша рука. В результате метод onSensorChanged
вызывается несколько раз в секунду. Нам не нужны все эти данные, поэтому нам нужно убедиться, что мы отбираем только подмножество данных, которые мы получаем от акселерометра устройства. Мы сохраняем текущее время системы (в миллисекундах), сохраняем его в curTime
и проверяем, прошло ли более 100
миллисекунд с момента последнего onSensorChanged
.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
public void onSensorChange(SensorEvent sensorEvent) {
Sensor mySensor = sensorEvent.sensor;
if (mySensor.getType() == Sensor.TYPE_ACCELEROMETER) {
float x = sensorEvent.values[0];
float y = sensorEvent.values[1];
float z = sensorEvent.values[2];
long curTime = System.currentTimeMillis();
if ((curTime — lastUpdate) > 100) {
long diffTime = (curTime — lastUpdate);
lastUpdate = curTime;
}
}
}
|
Последняя часть головоломки — определение того, было ли устройство потрясено или нет. Мы используем класс Math
для расчета скорости устройства, как показано ниже. Статически объявленная переменная SHAKE_THRESHOLD
используется, чтобы увидеть, был ли обнаружен жест встряхивания или нет. Изменение SHAKE_THRESHOLD
увеличивает или уменьшает чувствительность, поэтому не стесняйтесь играть с ее значением.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public void onSensorChange(SensorEvent sensorEvent) {
Sensor mySensor = sensorEvent.sensor;
if (mySensor.getType() == Sensor.TYPE_ACCELEROMETER) {
float x = sensorEvent.values[0];
float y = sensorEvent.values[1];
float z = sensorEvent.values[2];
long curTime = System.currentTimeMillis();
if ((curTime — lastUpdate) > 100) {
long diffTime = (curTime — lastUpdate);
lastUpdate = curTime;
float speed = Math.abs(x + y + z — last_x — last_y — last_z)/ diffTime * 10000;
if (speed > SHAKE_THRESHOLD) {
}
last_x = x;
last_y = y;
last_z = z;
}
}
}
|
2. Завершение лотереи
Теперь у нас есть приложение, которое может определять жест дрожания с помощью акселерометра. Давайте закончим этот проект, используя жест встряхивания, чтобы выбрать шесть случайных номеров лотереи. Я покажу вам, как генерировать случайное число от 1
до 49
, но вы можете изменить мою реализацию, чтобы она соответствовала тому, как разыгрываются лотереи в вашей стране.
Давайте начнем с настройки основного файла макета приложения, который мы будем использовать для пользовательского интерфейса. Как вы можете видеть ниже, я использую шесть рамок с фоном изображения шара.
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
|
<?xml version=»1.0″ encoding=»utf-8″?>
<LinearLayout xmlns:android=»http://schemas.android.com/apk/res/android»
android:orientation=»vertical»
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»>
<LinearLayout
android:layout_height=»wrap_content»
android:layout_width=»fill_parent»
android:weightSum=»6″
android:orientation=»horizontal»>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:layout_weight=»2″
android:id=»@+id/ball_1″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_1″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:layout_weight=»2″
android:id=»@+id/ball_2″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_2″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:layout_weight=»2″
android:id=»@+id/ball_3″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_3″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
</LinearLayout>
<LinearLayout
android:layout_height=»wrap_content»
android:layout_width=»fill_parent»
android:weightSum=»6″
android:orientation=»horizontal»>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:layout_weight=»2″
android:id=»@+id/ball_4″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_4″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:id=»@+id/ball_5″
android:layout_weight=»2″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_5″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
<FrameLayout
android:layout_height=»wrap_content»
android:layout_width=»wrap_content»
android:layout_margin=»5dp»
android:id=»@+id/ball_6″
android:layout_weight=»2″
android:background=»@drawable/blue»>
<TextView
android:layout_width=»fill_parent»
android:layout_height=»fill_parent»
android:id=»@+id/number_6″
android:gravity=»center»
android:layout_gravity=»center_vertical»
android:textColor=»@android:color/white»/>
</FrameLayout>
</LinearLayout>
</LinearLayout>
|
Каждый макет кадра содержит текстовое представление, в котором будет отображаться случайно сгенерированный номер лотереи. Обратите внимание, что у каждого макета кадра и текстового представления есть id
, чтобы мы могли ссылаться на них позже.
Когда основной макет готов к использованию, давайте вернемся к классу Main
. Мы начнем с создания getRandomNumber
, частного метода для генерации шести случайных чисел от 1
до 49
.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
private void getRandomNumber() {
ArrayList numbersGenerated = new ArrayList();
for (int i = 0; i < 6; i++) {
Random randNumber = new Random();
int iNumber = randNumber.nextInt(48) + 1;
if(!numbersGenerated.contains(iNumber)) {
numbersGenerated.add(iNumber);
} else {
i—;
}
}
}
|
Сначала мы создаем экземпляр ArrayList
, который мы используем для хранения шести чисел. В каждом цикле цикла for
мы используем Java-класс Random
для генерации случайного числа. Чтобы убедиться, что мы получаем число от 1
до 49
, мы добавляем 1
к результату. Следующим шагом является проверка, находится ли сгенерированный номер в списке массивов, потому что нам нужны только уникальные номера в списке массивов.
Обратите внимание, что может потребоваться добавить еще два оператора импорта, чтобы компилятор был доволен.
1
2
|
import java.util.ArrayList;
import java.util.Random;
|
Последний шаг — отображение случайно сгенерированного числа в пользовательском интерфейсе. Мы получаем ссылку на текстовые представления, которые мы создали ранее, и заполняем каждое текстовое представление случайным числом. Мы также добавляем аккуратную анимацию в макеты кадров, но не стесняйтесь опускать или изменять анимацию.
01
02
03
04
05
06
07
08
09
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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
private void getRandomNumber() {
ArrayList numbersGenerated = new ArrayList();
for (int i = 0; i < 6; i++) {
Random randNumber = new Random();
int iNumber = randNumber.nextInt(48) + 1;
if(!numbersGenerated.contains(iNumber)) {
numbersGenerated.add(iNumber);
} else {
i—;
}
}
TextView text = (TextView)findViewById(R.id.number_1);
text.setText(«»+numbersGenerated.get(0));
text = (TextView)findViewById(R.id.number_2);
text.setText(«»+numbersGenerated.get(1));
text = (TextView)findViewById(R.id.number_3);
text.setText(«»+numbersGenerated.get(2));
text = (TextView)findViewById(R.id.number_4);
text.setText(«»+numbersGenerated.get(3));
text = (TextView)findViewById(R.id.number_5);
text.setText(«»+numbersGenerated.get(4));
text = (TextView)findViewById(R.id.number_6);
text.setText(«»+numbersGenerated.get(5));
FrameLayout ball1 = (FrameLayout) findViewById(R.id.ball_1);
ball1.setVisibility(View.INVISIBLE);
FrameLayout ball2 = (FrameLayout) findViewById(R.id.ball_2);
ball2.setVisibility(View.INVISIBLE);
FrameLayout ball3 = (FrameLayout) findViewById(R.id.ball_3);
ball3.setVisibility(View.INVISIBLE);
FrameLayout ball4 = (FrameLayout) findViewById(R.id.ball_4);
ball4.setVisibility(View.INVISIBLE);
FrameLayout ball5 = (FrameLayout) findViewById(R.id.ball_5);
ball5.setVisibility(View.INVISIBLE);
FrameLayout ball6 = (FrameLayout) findViewById(R.id.ball_6);
ball6.setVisibility(View.INVISIBLE);
Animation a = AnimationUtils.loadAnimation(this, R.anim.move_down_ball_first);
ball6.setVisibility(View.VISIBLE);
ball6.clearAnimation();
ball6.startAnimation(a);
ball5.setVisibility(View.VISIBLE);
ball5.clearAnimation();
ball5.startAnimation(a);
ball4.setVisibility(View.VISIBLE);
ball4.clearAnimation();
ball4.startAnimation(a);
ball3.setVisibility(View.VISIBLE);
ball3.clearAnimation();
ball3.startAnimation(a);
ball2.setVisibility(View.VISIBLE);
ball2.clearAnimation();
ball2.startAnimation(a);
ball1.setVisibility(View.VISIBLE);
ball1.clearAnimation();
ball1.startAnimation(a);
}
|
Нам нужно добавить еще несколько операторов import, чтобы все это работало. Посмотрите на фрагмент кода ниже.
1
2
3
4
5
|
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.TextView;
|
Что касается анимации, взгляните на содержимое файла анимации ниже. Обратите внимание, что вам нужно создать папку anim
в директории ресурсов вашего проекта и move_down_ball_first.xml
ей имя move_down_ball_first.xml
. Регулируя значения элемента scale
, вы можете изменить продолжительность анимации и положение каждого шара.
01
02
03
04
05
06
07
08
09
10
11
12
|
<?xml version=»1.0″ encoding=»utf-8″?>
<set xmlns:android=»http://schemas.android.com/apk/res/android»
android:fillAfter=»true»
android:interpolator=»@android:anim/bounce_interpolator»>
<scale
android:duration=»1500″
android:fromXScale=»1.0″
android:fromYScale=»-10.0″
android:toXScale=»1.0″
android:toYScale=»1.0″ />
</set>
|
Нам осталось только вызвать getRandomNumber
в onSensorChanged
в классе Main
. Посмотрите на полную реализацию onSensorChanged
показанную ниже.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
public void onSensorChange(SensorEvent sensorEvent) {
Sensor mySensor = sensorEvent.sensor;
if (mySensor.getType() == Sensor.TYPE_ACCELEROMETER) {
float x = sensorEvent.values[0];
float y = sensorEvent.values[1];
float z = sensorEvent.values[2];
long curTime = System.currentTimeMillis();
if ((curTime — lastUpdate) > 100) {
long diffTime = (curTime — lastUpdate);
lastUpdate = curTime;
float speed = Math.abs(x + y + z — last_x — last_y — last_z)/ diffTime * 10000;
if (speed > SHAKE_THRESHOLD) {
getRandomNumber();
}
last_x = x;
last_y = y;
last_z = z;
}
}
}
|
Вывод
В этом уроке я показал вам, как работает акселерометр и как вы можете использовать его для обнаружения движения дрожания. Конечно, есть много других вариантов использования акселерометра. Имея базовые знания об обнаружении жестов с помощью акселерометра, я рекомендую вам поэкспериментировать с акселерометром, чтобы узнать, что еще вы можете с ним сделать.
Если вы много работаете над разработкой для Android, вы, вероятно, столкнетесь с ситуациями, когда вам понадобится помощь с конкретным аспектом, который не является вашей специальностью. Если это так, попробуйте нанять одного из опытных разработчиков приложений в Envato Studio, чтобы выполнить работу быстро и надежно.