Доброго времени суток всем читателям Хабр.
Поиск работы в it сфере сильно отличается от обычных профессий и порог вхождения на любую позицию в it намного выше большинства профессий. Традиционно при отклике на вакансию просят сделать тестовое задание, это является своеобразным фильтром, каждый раз возникает дилемма, делать его или не делать, ведь никому не хочется впустую потратить время и не получить в ответ ни приглашения на собеседование ни тем более приглашения на работу.
В данной статье я хотел бы разобрать тестовое задание на позицию Android developer и продемонстрировать свой способ решения.
Начнем с ознакомления с ТЗ задания
После прочтения ТЗ предлагаю проанализировать, из чего будет состоять приложение и что мы будем делать чтобы успешно его реализовать.
Начнем с того что в данном ТЗ кроме требований к языку (код должен быть написан на Kotlin), есть требования к архитектуре приложения (MVVM или MVP), так же сразу становится понятно, что нам понадобится список (не простой список конечно, нужна будет сортировка по дате, об этом я напишу далее) и в этом списке мы будем отображать GET запрос, который получим по API. Что же, на первый взгляд ТЗ не выглядит сложным, из интересного это сортировка по дате. Теперь можно приступать к решению!
Шаг 1. Структура
Для начала предлагаю подумать об архитектуре
Структура проекта будет следующая
ui – в этой папке будет храниться все что связано с ui интерфейсом: MainFragment – фрагмент где будет отображаться список, MainViewModelFactory – фабрика для создания экземпляра ViewModelTraining. ViewModelTraining – вью модель для работы с данными и отображения в MainFragment.
adapter – в этой паке будет класс TrainingAdapter, который отвечает за связь данных с ui
api – в данной паке будет BASE_URL, интерфейс ApiService с GET запросом, а так же ApiResult с обработкой результатов работы с API.
data - в этой паке будут модели, которые мы будем отображать с помощью GET запроса.
di - в этой папке будет внедрение зависимостей
Шаг 2. Реализация
Теперь приступаем к самому интересному, к реализации.
Подключаем библиотеки. Нам понадобятся:
viewBinding - для облегчения доступа к элементам пользовательского интерфейса.
buildFeatures {
viewBinding true
}
Lifecycle - чтобы обеспечить более удобное и надежное управление жизненным циклом компонентов.
// Lifecycle
def lifecycleVersion = "2.4.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
Coroutines - для работы с асинхронным кодом.
// Coroutines
def coroutinesVersion = "1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
Retrofit - для работы с API.
// Retrofit
def retrofitVersion = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
Navigation Components - в данном случае не обязательно, но обычно я использую именно Navigation Components для работы с fragments.
// Navigation Components
def navVersion = "2.5.3"
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
Верстаем разметку.
В данном приложении будет single activity, поэтому activity_main будет содержать FragmentContainerView.
С фрагментами, на мой взгляд, лучше использовать navigation, поскольку это более лаконично и позволит масштабировать приложение, если это понадобится
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="androidx.navigation.fragment.NavHostFragment"
app:defaultNavHost = "true"
app:navGraph="@navigation/nav_graph"
app:layout_constraintBottom_toTopOf="@id/bottom_nav_menu"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
/>
Кроме этого, в примере присутствует BottomNavigationView без возможности перехода на другие Fragments, так же реализуем ее в нашем activity_main.
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav_menu"
android:layout_width="match_parent"
android:layout_height="50dp"
app:menu="@menu/bottom_nav_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
Далее fragment_main где будет список, для списка используем recyclerview.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/training_adapter"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:clipToPadding="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="MissingConstraints" />
Для него будет основной listitem, в нем мы будем отображать то что будет в списке.
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="10dp"
android:layout_margin="8dp"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/frame">
<TextView
android:id="@+id/startTime"
android:layout_width="50dp"
android:layout_height="20dp"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:text="10:00"
app:layout_constraintStart_toEndOf="@+id/color"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/lastTime"
android:layout_width="50dp"
android:layout_height="20dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="11:00"
app:layout_constraintStart_toEndOf="@+id/color"
app:layout_constraintTop_toBottomOf="@+id/startTime" />
<TextView
android:id="@+id/trening"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="32dp"
android:text="Силовая тренировка"
app:layout_constraintStart_toEndOf="@+id/startTime"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/trainingTime"
android:layout_width="100dp"
android:layout_height="20dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="32dp"
android:text="1ч. 15мин."
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/name"
android:layout_width="150dp"
android:layout_height="20dp"
android:layout_marginTop="16dp"
android:text="Григорьев Ярослав"
app:layout_constraintStart_toEndOf="@+id/profile"
app:layout_constraintTop_toBottomOf="@+id/trening" />
<TextView
android:id="@+id/hall"
android:layout_width="100dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="Студия 2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/geo"
app:layout_constraintTop_toBottomOf="@+id/trainingTime" />
<ImageView
android:id="@+id/profile"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:background="@drawable/ic_person"
app:layout_constraintStart_toEndOf="@+id/lastTime"
app:layout_constraintTop_toBottomOf="@+id/trening" />
<ImageView
android:id="@+id/geo"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="4dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="7dp"
android:background="@drawable/ic_location"
app:layout_constraintEnd_toStartOf="@+id/hall"
app:layout_constraintStart_toEndOf="@+id/name"
app:layout_constraintTop_toBottomOf="@+id/trainingTime" />
<TextView
android:id="@+id/color"
android:layout_width="5dp"
android:layout_height="100dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
На макете это будет выглядеть примерно так:
Кроме этого нужен header_item для отображения даты
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20dp"
android:layout_gravity="center_vertical"
android:textColor="@color/gray"/>
</FrameLayout>
MainActivity код будет выглядеть так:
class MainActivity : AppCompatActivity() {
private var _binding: ActivityMainBinding? = null
private val mBinding get() = _binding!!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
mBinding.bottomNavMenu.setupWithNavController(navController)
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}
Данный код создает экземпляр активности MainActivity
, привязывает разметку активности, устанавливает ее в качестве контента активности, настраивает навигацию и связывает нижнее меню навигации.
С помощью расширения Kotlin data class File from JSON создаем дата классы для модели.
data class Training(
@SerializedName("lessons")
val lessons: List<Lesson>,
@SerializedName("option")
val option: Option,
@SerializedName("tabs")
val tabs: List<Tab>,
@SerializedName("trainers")
val trainers: List<Trainer>
)
data class Trainer(
@SerializedName("description")
val description: String,
@SerializedName("full_name")
val fullName: String,
@SerializedName("id")
val id: String,
@SerializedName("image_url")
val imageUrl: String,
@SerializedName("image_url_medium")
val imageUrlMedium: String,
@SerializedName("image_url_small")
val imageUrlSmall: String,
@SerializedName("last_name")
val lastName: String,
@SerializedName("name")
val name: String,
@SerializedName("position")
val position: String
)
data class Tab(
@SerializedName("id")
val id: Int,
@SerializedName("name")
val name: String
)
data class Option(
@SerializedName("club_name")
val clubName: String,
@SerializedName("link_android")
val linkAndroid: String,
@SerializedName("link_ios")
val linkIos: String,
@SerializedName("primary_color")
val primaryColor: String,
@SerializedName("secondary_color")
val secondaryColor: String
)
data class Lesson(
@SerializedName("appointment_id")
val appointmentId: String,
@SerializedName("available_slots")
val availableSlots: Int,
@SerializedName("client_recorded")
val clientRecorded: Boolean,
@SerializedName("coach_id")
val coachId: String,
@SerializedName("color")
val color: String,
@SerializedName("commercial")
val commercial: Boolean,
@SerializedName("date")
val date: String,
@SerializedName("description")
val description: String,
@SerializedName("endTime")
val endTime: String,
@SerializedName("is_cancelled")
val isCancelled: Boolean,
@SerializedName("name")
val name: String,
@SerializedName("place")
val place: String,
@SerializedName("service_id")
val serviceId: String,
@SerializedName("startTime")
val startTime: String,
@SerializedName("tab")
val tab: String,
@SerializedName("tab_id")
val tabId: Int
)
enum class TrainingType понадобится в будущем для работы в адаптере.
4. Внедряем зависимости, поскольку приложение небольшое, я решил не использовать Hilt.
class App : Application() {
private lateinit var viewModelFactory: MainViewModelFactory
private lateinit var retrofit: ApiService
override fun onCreate() {
super.onCreate()
retrofit = Retrofit.Builder()
.baseUrl(RetrofitClient.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build().create(ApiService::class.java)
viewModelFactory = MainViewModelFactory(retrofit)
}
fun <T : ViewModel> provideViewModel(clazz: Class<T>, owner: ViewModelStoreOwner) =
ViewModelProvider(owner, viewModelFactory)[clazz]
}
Данный код создает экземпляры retrofit
и viewModelFactory
в методе onCreate()
, и предоставляет экземпляры ViewModel через функцию provideViewModel()
.
В классе App мы используем ApiService и MainViewModelFactor, создадим их немного позже
Не забываем указать App в манифесте.
android:name=".di.App"
5. Пишем ретрофит.
Тут все стандартно, интерфейс с запросом, обработка ответов и BASE_URL, напомню, билдер находится в App. Так же нужно указать работу с сетью в манифесте.
class RetrofitClient {
companion object {
val BASE_URL = "https://olimpia.fitnesskit-admin.ru/"
}
}
В классе RetrofitClient указываем BASE_URL
interface ApiService {
@GET("schedule/get_v3/?club_id=2")
suspend fun getTrainingList(): Response<Training>
}
В интерфейсе ApiService пишем GET запрос который будет возвращать Response<Training>
sealed class ApiResult<out T>(
val data: T?,
val errorMessage: String?
) {
class Success<out T>(_data: T?) : ApiResult<T>(
data = _data,
errorMessage = null
)
class Error<out T>(
val exception: String
) : ApiResult<T>(
data = null,
errorMessage = exception
)
}
Класс ApiResult
предоставляет общую структуру для представления результатов операций API. Подклассы Success
и Error
предоставляют специфические реализации для успешных результатов и ошибок соответственно.
<uses-permission android:name="android.permission.INTERNET"/>
Обязательно указываем работу с сетью.
Пишем адаптер
Адаптер это часто используемый паттерн в андроид разработке он есть в любом приложении где есть список. В данном случае задача усложнена тем, что нам нужно отобразить не один listitem, а еще и header_item (обычно мы отображаем какой-то один список).
class TrainingAdapter(private val getTrainerById: (String) -> String) :
RecyclerView.Adapter<TrainingAdapter.TrainingViewHolder>() {
class TrainingViewHolder(view: View) : RecyclerView.ViewHolder(view)
private var training: List<LessonEntity> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrainingViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val viewToInflate = when (viewType) {
TrainingType.TRAIN.ordinal -> R.layout.listitem
else -> R.layout.header_item
}
return TrainingViewHolder(layoutInflater.inflate(viewToInflate, parent, false))
}
override fun getItemViewType(position: Int): Int {
return training[position].type.ordinal
}
override fun onBindViewHolder(holder: TrainingViewHolder, position: Int) {
val item = training[position]
if (item.type == TrainingType.TRAIN) {
with(holder.itemView) {
findViewById<TextView>(R.id.startTime).text = item.lesson?.startTime
findViewById<TextView>(R.id.lastTime).text = item.lesson?.endTime
findViewById<TextView>(R.id.trening).text = item.lesson?.name
findViewById<TextView>(R.id.color).setBackgroundColor(Color.parseColor(item.lesson?.color))
item.lesson?.coachId?.let {
findViewById<TextView>(R.id.name).text = getTrainerById(it)
}
}
} else {
holder.itemView.findViewById<TextView>(R.id.date).text = item.header
}
}
override fun getItemCount() = training.size
@SuppressLint("NotifyDataSetChanged")
fun setData(data: List<LessonEntity>) {
training = data
notifyDataSetChanged()
}
}
В TrainingViewHolder в конструкторе которого будет не (val binding: ListitemBinding), а будет (view: View), таким образом мы сможем обращаться и к списку и к дате.
Метод onCreateViewHolder
переопределен для создания экземпляра TrainingViewHolder
и в зависимости от типа элемента, определенного в viewType
. Возвращается экземпляр TrainingViewHolder
, связанный с inflate.
Метод getItemViewType
переопределен для получения типа элемента по его позиции в списке. Возвращается значение ordinal
типа элемента TrainingType
.
Метод onBindViewHolder
переопределен для связывания данных с представлениями элементов списка. Если тип элемента TrainingType.TRAIN
, то значения связываются с соответствующими представлениями (TextView) внутри TrainingViewHolder.itemView
. Если тип элемента отличается от TrainingType.TRAIN
, то значение связывается с TextView внутри TrainingViewHolder.itemView
, которые отображают заголовок элемента.
Метод getItemCount
возвращает количество элементов в списке training
.
Метод setData
используется для установки новых данных в адаптер и вызова notifyDataSetChanged()
, чтобы обновить список.
Отображаем все в MainFragment и сортируем список .
Для этого создадим класс MainViewModelFactory.
class MainViewModelFactory constructor(
private val retrofit: ApiService
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ViewModelTraining::class.java)) {
return ViewModelTraining(retrofit) as T
}
throw java.lang.IllegalArgumentException("Unknown class name")
}
}
MainViewModelFactory
используется для создания экземпляров ViewModelTraining
с использованием ApiService
в качестве зависимости.
Создаем ViewModelTraining.
class ViewModelTraining(private val retrofit: ApiService) : ViewModel() {
var liveData = MutableLiveData<ApiResult<Training>>()
init {
loadListTraining()
}
fun loadListTraining() {
viewModelScope.launch(Dispatchers.IO) {
val result = retrofit.getTrainingList()
if (result.isSuccessful) {
liveData.postValue(ApiResult.Success(result.body()))
} else liveData.postValue(ApiResult.Error("Not Loading"))
}
}
fun getTrainerById(id: String): String {
val trainer = liveData.value?.data?.trainers?.find {
it.id == id
}
return trainer?.name ?: " "
}
}
Внутри класса ViewModelTraining
определена переменная liveData
, которая является экземпляром MutableLiveData
с параметризованным типом ApiResult<Training>
. MutableLiveData
предоставляет возможность наблюдать за изменениями данных и обновлять пользовательский интерфейс при их изменении.
В блоке init
определен инициализационный блок, который вызывает метод loadListTraining()
. Этот метод используется для загрузки списка тренировок и обновления значения liveData
.
Метод loadListTraining()
использует viewModelScope.launch
для запуска корутины в контексте Dispatchers.IO
. Внутри корутины выполняется запрос к методу getTrainingList()
объекта retrofit
. Если запрос успешен, то результат помещается в liveData
с помощью liveData.postValue(ApiResult.Success(result.body()))
, иначе помещается сообщение об ошибке с помощью liveData.postValue(ApiResult.Error("Not Loading"))
.
Метод getTrainerById
принимает аргумент id
типа String
и используется для получения имени тренера по его идентификатору. Внутри метода используется функция find
для поиска тренера в списке данных liveData
. Если тренер найден, то возвращается его имя, иначе возвращается пустая строка.
Создаем MainFragment
class MainFragment : Fragment() {
private var _binding: FragmentMainBinding? = null
private val mBinding get() = _binding!!
private lateinit var viewModel: ViewModelTraining
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentMainBinding.inflate(layoutInflater, container, false)
return mBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel =
(activity?.application as App).provideViewModel(ViewModelTraining::class.java, this)
val trainingAdapter = TrainingAdapter { viewModel.getTrainerById(it) }
mBinding.trainingAdapter.adapter = trainingAdapter
viewModel.liveData.observe(requireActivity()) {
if (it is ApiResult.Success && it.data != null) {
trainingAdapter.setData(sortLessons(it.data))
} else {
Toast.makeText(requireContext(), it.errorMessage, Toast.LENGTH_SHORT).show()
}
}
}
fun sortLessons(body: Training): ArrayList<LessonEntity> {
val list = body.lessons.sortedByDescending {
it.formatedDate
}
val map = emptyMap<String, ArrayList<Lesson>>().toMutableMap()
list.forEach {
if (map[it.dateWithDay] == null)
map[it.dateWithDay] = ArrayList()
map[it.dateWithDay]?.add(it)
}
val newList = ArrayList<LessonEntity>()
map.forEach() { entry ->
newList.add(LessonEntity(type = TrainingType.HEADER, null, entry.key))
entry.value.mapTo(newList) {
LessonEntity(type = TrainingType.TRAIN, it, null)
}
}
return newList
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}
Создается приватное свойство _binding
типа FragmentMainBinding?
, которое используется для привязки разметки фрагмента. Создается свойство mBinding
, которое возвращает _binding!!
, чтобы обеспечить доступ к привязке фрагмента.
Создается свойство viewModel
типа ViewModelTraining
, которое будет использоваться для обработки бизнес-логики и управления данными, связанными с тренировками.
В методе onCreateView
создается привязка разметки фрагмента с использованием _binding
, который получает доступ к разметке фрагмента с помощью FragmentMainBinding.inflate(layoutInflater, container, false)
. Затем возвращается корневой вид разметки.
В методе onViewCreated
происходит инициализация viewModel
, используя provideViewModel
из класса App
, который предоставляет экземпляр ViewModelTraining
с использованием activity?.application
и передает текущий фрагмент this
. Затем создается экземпляр TrainingAdapter
, передавая лямбда-выражение viewModel.getTrainerById(it)
в конструктор. Далее устанавливается адаптер для mBinding.trainingAdapter.adapter
. Наблюдатель liveData
в viewModel
слушает изменения и обновляет UI в соответствии с результатами. Если результат является ApiResult.Success
и данные не равны null
, вызывается setData
для trainingAdapter
с отсортированными данными. В противном случае показывается Toast
с сообщением об ошибке.
В классе определен метод sortLessons
, который принимает объект Training
и возвращает список LessonEntity
. В этом методе происходит сортировка списка lessons
по убыванию даты. Затем создается пустой изменяемый словарь map
, куда добавляются элементы из списка lessons
с группировкой по дате. Затем создается новый список newList
, в который добавляются заголовки и тренировки из словаря map
. Наконец, возвращается отсортированный список newList
.
В методе onDestroy
_binding
устанавливается в null
, чтобы избежать утечки памяти.
Все, задние готово! На эмуляторе выглядит приложение так:
В заключение хочу написать, что такого рода тестовые, где вас просят отобразить что-нибудь с помощью запросов, встречаются часто. Думаю, мой разбор данного тестового может быть полезен новичкам, которые планируют стать Андроид Разработчиками и только начинают искать работу и пробуют откликаться на вакансии. Делать тестовые или нет, ваш выбор, возможно вам повезет и после тестового вас позовут на собеседование.
Более подробно с кодом можете ознакомиться тут
ТГ для обратной связи: @AndrewNB
Комментарии (7)
avvsof2050
05.07.2023 20:27Такие простые задания последнее время не встречались. Это из 2020 года. Сейчас разметка будет в разы сложнее, зададут какой нибудь вычурный макет в фигме, Dugger Hilt обязательно, сохранять в бд обязательно, модули по фичам, и несколько экранов с различными recycler'ами. И тд. Плюс к этому всё чаще Compose. А, тесты не забудь. Поиск какой нибудь извращенный. Архитектура - чистая, как слеза, туда же.
AndreyBN95 Автор
05.07.2023 20:27Тестовое этого года. В большинстве случаев встречаются +- такие тестовые, но бывают и большие тестовые задания где нужно показать умения владеть всем что понадобится для разработки, думаю их полезно решать для своего опыта, чтобы потренироваться, но для трудоустройства, не думаю.
Вообще странно, когда дают большие тестовые без внятных дальнейших перспектив, но кому интересны пожелания человека который устраивается на позицию android developer без вменяемого опыта, сейчас такой рынок с такими условиями, не будешь решать большое тестовое, кто-нибудь другой решит.
Остается надеяться что количество сделанных тестовых перерастет в качество и в приглашение на работу)
avvsof2050
05.07.2023 20:27не будешь решать большое тестовое, кто-нибудь другой решит.
Там сейчас килополя кандидатов на стажировку-Junior позиции. Требования примерно как раньше были к middle++. Работодатели могут "перебирать харчами", так как много (очень много) хороших кандидатов. Сейчас это больше выглядит, как нанять мидла за ЗП стажера, или вообще бесплатно для начала. Видел уже стажировки, где нужно самому кандидату платить за участие)))
AndreyBN95 Автор
05.07.2023 20:27Да, конкуренция сейчас большая, нужно быть готовым сразу показывать результат.
ptyrss
Побуду хейтером, но я бы при виде такого кода начал задавать много вопросов.
Вёрстку особо трогать не буду, но что там абсолютные значения высоты - а задумывались что будет если фамилия например длинная и не влезет в одну строку?
ApiResult
выглядит опасно, у вас можно быть в теорииSuccess
с null значением? Мне кажется это уже совсем не успех.Адаптер - смесь when и if выглядит странно, плюс у вас адаптер и холдер склеены в один класс.
Куча мест где есть проверка на null выглядит странно, чисто для примера
Color.parseColor(item.lesson?.color)
что тут будет если цвет null?sortLessons
выглядит максимально странно, создание мутабельного map куча конвертов данных, отдача изменяемого списка.Rusrst
Да ладно, все с чего-то начинают :)
Будет стараться все получится. Правда дизайн от реализации далёк конечно...
AndreyBN95 Автор
Отвечу на некоторые замечания
По верстке да, есть такой недочет длинные ФИО налезают на соседнюю View.
По поводу ApiResult, согласен, сейчас я использую другой способ обработки ответов сервера.
По поводу проверки на null, на серьезном проекте да, могут поступать разные данные, могут и с null, но в данном тестовом в ответе всегда есть данные, поэтому приложение не упадет.
sortLessons на момент написания тестового такой способ сортировки и заполнения мне казался хорошим решением.
Спасибо за то что указали на недочеты, безусловно мне есть над чем поработать, в следующих своих работах буду учитывать такие моменты. Собственно обратная связь и практика помогают стать лучше)