Приветствую!
Сегодня, как всегда, поговорим о создании мобильных приложений с фреймворком Kivy и Python. В частности речь пойдет о создании мобильного клиента для одного Интернет ресурса и публикации его в Google Play. Я расскажу, с какими проблемами может столкнуться новичок и опытный разработчик, которые решили попробовать себя в кроссплатформенной разработке с Kivy, что можно и чего лучше не делать в программировании с Python for Android.
Как-то утром я обнаружил в своей почте на Хабре письмо с вопросом, могу ли я с помощью Python и Kivy «воссоздать сайт svyatye.com в мобильном приложении, так чтобы люди могли читать и пользоваться им в offline режиме» с последующей публикацией клиента в магазине приложений Google Play. Пройдя по ссылке и просматривая ресурс, который оказался большой библиотекой цитат, я мысленно представлял, как это будет выглядеть в мобильном представлении и каким образом я буду создавать списки «более 30 236 изречений святых отцов и учителей церкви» при том, что длинна цитат, порою, достигала свыше 10 000 символов (5-6 страниц печатного текста). Поскольку я уже давно работаю с Kivy, то довольно быстро понял, как и что буду делать. Поэтому ответил заказчику, что сделать такое приложение особого труда не составит. Однако трудности, о которых я расскажу ниже, все же возникли…
Никакого технического задания не предоставлялось. Единственное требование — приложение должно работать, как часы. Сроки не ставились. Макетов интерфейса тоже не было. 'Всё должно быть максимально просто, без анимаций, трансформаций и прочей шелухи, словом, как можно аскетичней'. Что ж, тем лучше. Тем более, что у меня уже созрело решение — приложение будет использовать один объект RecycleView, в котором будут отображаться категории, подкатегории, списки авторов цитат и сами цитаты.
Списки
Однако RecycleView, который позволяет за доли секунды открывать огромнейшие многотысячные списки, повел себя совсем не так, как хотелось. Нет, проблем с открытием списков цитат не было, все работало быстро, я даже не стал делать подгрузку новых цитат с окошком «Подождите», как на сайте, потому что список цитат выбранной категории рендерился мгновенно и полностью. Проблема заключал в другом — заказчик настаивал, чтобы текст цитаты в списке отображался целиком и RecycleView здесь был не совсем уместен. Дело в том, что принцип работы данного виджета заключается в следующем: на весь список создается один объект, который в дальшейшем просто клонируется, в результате чего мы имеем потрясающую скорость рендера списка, каким бы большим он не был. Но есть одно но — высота элемента списка должна быть фиксирована и заранее известна. А вот если требуется динамически при скролле вычислять высоту следующего элемента списка, как в моем случае, то происходит заметный лаг — список на долю секунды фризится, что, согласитесь, отнюдь не prodaction ready.
С горем пополам мне удалось уговорить заказчика на список с превью цитат, текст которых открывался бы целиком по тапу на текст превью, как это сделано практически на любом форуме, не потому, что RecycleView не смог справится с поставленой задачей, а потому, что это было наиболее логично: скроллить многостраничный текст цитаты, особенно если цитата не заинтересовала пользователя, с моей точки зрения было не правильно.
Рис. 1
Превью и полный текст при тапе на превью цитаты
Этот вариант работал очень быстро, но… заказчику не понравился… Пришлось использовать медленный ScrollView, который рендерит список ДО его вывода на экран, а, значит, позволит не фризить скроллинг списка цитат, так как вычислит и отрендерит все параметры элементов списка заранее, что, естественно, скажется на скорости вывода списка на экран. Обычно на первое место ставят производительность, а тут мне говорят, «пусть будет медленней».
Что ж, больше спорить я не стал и хоть мне жутко не нравилось это решение, пришлось все передалать под ScrollView. Поскольку, как я уже сказал, ScrollView очень медленный, было решено выводить цитаты порциями по десять штук с дальнейшей автоматической подгрузкой следующего десятка.
Забегая немного вперед, скажу, что когда пришли первые фидбэки от пользователей с просьбой, мол, уж очень бы не помешали закладки, как мне кажется, заказчик все же засомневался в правильности решения использовать ScrollView, так как если бы мы оставили превьюшки цитат и RecycleView, то без проблем могли бы мгновенно востанавливать по закладкам ранее просмотренные пользователем в предыдущей сессии списки цитат, какими бы длинными они не были. А со ScrollView пользователь просто состарится пока дождется вывода списка хотя бы из духста цитат.
Buildozer и сервисы
По мере разработки приложения поступило предложение запилить в нем сервис, который бы раз в сутки присылал рандомную цитату из базы пользователю. Никогда раньше не имел дело с подобными задачами в Kivy, но помня, что на Хабре есть статья по этому вопросу, решил попробовать.
Убив целую неделю, разбив пять клавиатур и два монитора, мне так и не удалось собрать пакет по инструкции из вышеуказанной статьи — при компиляции не находился нужный класс. Написав автору статьи, я предположил, что истинны, видимо, только две причины по которым я потерпел неудачу: либо я идиот либо разработчики сломали Buildozer — инструмент для сборки APK пакетов для Android. Мои предположения оказались верны — «Конечно, они его поломали, после 0.33 версии хрен им чего соберёшь».
Да, львиная доля вопросов на форуме Kivy связана различными проблемами, которые возникают именно с Buildozer. Сейчас каждая версия этого инструмента требует свою версию Cython, которую опытным путем вы будете подбирать долго, используя последние версии Buildozer вам не удастся добавить в свой проект JAR библиотеку, потому что хоть проект и соберется, библиотека не будет в него добавлена и вы еще одну неделю, как и я, просидите в поиске проблемы. И… не найдете ее. Поэтому для новичков и людей со слабой психикой работа с Buildozer может довести до поликлиники.
Поэтому я плюнул на этот убитый трактор, удалив его к чертям, пошел на github, скачал python-for-android, на офф сайте взял Crystax-NDK, поставил Python 3.5 и преспокойно собрал APK проекта с третьей веткой Python, что оказалось намного проще, чем с пресловутым Buildozer.
А что же насчет сервисов? А ничего. Они не работают. Точнее, созданный в вашем проекте сервис не будет стартовать с перезагрузкой смартфона, что бы там ни утверждал автор статьи, о сервисах в Kivy. Найдя в Google Play и установив его проект я обнаружил, что никакие сервисы с рестартом программы не запускаются. 100% сервисы в Kivy стартуют только вместе с запуском самого приложения. В последствии, если вы закроете приложение, сервис спокойно будет работать дальше до момента пока вы не выключите устройство.
О Python 2 и Python 3
В феврале этого года проходил Moscow Python в московском офисе Яндекса, в котором Владислав Шашков выступал с докладом на тему «Мобильное приложение на Python c kivy/buildozer — ключ к успеху». Так вот он имел глупость сказать, что Python 2 в APK сборке работает быстрее Python 3. Никому не верте, это не правда. Python 3 работает быстрее Python 2 в принципе! Когда я разрабатывал «Цитаты Святых» (тогда еще предполагалось, что в сборке будет использована вторая ветка Python), то с ужасом обнаружил, что база цитат размером в 20 Мб., которая используется в приложении, когда отсутствует сетевое соеденение, читается посредством json.loads аж 13-16 секунд на мобильном устройстве! А та же база, но уже с Python 3 обрабатывается на девайсе за 1-2 секунды! Выводы делайте сами…
О React Native
Да, в своих статьях я решил проводить параллели между Kivy и другими фреймворками для кроссплатформенной разработки. Здесь вам просто нужно открыть спойлер и посмотреть, как просто, быстро и элегантно создаются приложения на React Native…
import React from 'react';
import {Container, Content} from 'native-base';
import {StyleSheet, Text, View} from 'react-native';
import AppFooter from './components/AppFooter.js';
const styles = StyleSheet.create({
container: {
padding: 20
},
});
const App = () => (
<Container>
<Content>
<View style={styles.container}>
<Text>
Lorem ipsum...
</Text>
</View>
</Content>
<AppFooter/>
</Container>
);
export default App;
Мы видим новый компонент AppFooter, который нам предстоит создать. Идём в папку ./components/ и создаём файл AppFooter.js со следующим содержимым:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
const AppFooter = () => (
<Footer>
<FooterTab>
<Button active>
<Text>Статьи</Text>
</Button>
<Button>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);
export default AppFooter;
Всё готово для того, чтобы попробовать собрать наше приложение!
Наши кнопки пока не умеют переключаться. Пора их научить. Для этого нужно сделать две вещи: научиться обрабатывать событие клика и научиться хранить состояние (state). Начнём с состояния. Так как мы отказались от хранения состояния в компоненте, сделав выбор в пользу чистых компонент и глобального стора, то будем использовать Redux.
Прежде всего, мы должны создать наш стор.
import {createStore} from 'redux';
const initialState = {};
const store = createStore(reducers, initialState);
Давайте создадим заготовку для редьюсеров. В папке reducers создаём файл index.js со следующим содержимым:
export default (state = [], action) => {
switch (action.type) {
default:
return state
}
};
Подключаем редьюсеры к App.js:
import reducers from './reducers';
Теперь нам необходимо распространить наш стор по компонентам. Делается это с помощью специально компоненты Provider. Подключаем её в проект:
import {Provider} from 'react-redux';
И оборачиваем все компоненты в Provider. Обновленный App.js выглядит так:
import React from 'react';
import {Container, Content} from 'native-base';
import {StyleSheet, Text, View} from 'react-native';
import AppFooter from './components/AppFooter.js';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducers from './reducers';
const initialState = {};
const store = createStore(reducers, initialState);
const styles = StyleSheet.create({
container: {
padding: 20
},
});
const App = () => (
<Provider store={store}>
<Container>
<Content>
<View style={styles.container}>
<Text>
Lorem ipsum...
</Text>
</View>
</Content>
<AppFooter/>
</Container>
</Provider>
);
export default App;
Теперь наше приложение может хранить своё состояние. Давайте воспользуемся этим. Добавляем состояние mode, по умолчанию установленное в ARTICLES. Это означает, что при первом рендере наше приложение будет установлено в состояние показа списка статей.
const initialState = {
mode: 'ARTICLES'
};
Неплохо, но ручное написание строковых значений ведёт к потенциальным ошибкам. Давайте заведём константы. Создаём файл ./constants/index.js со следющим содержимым:
export const MODES = {
ARTICLES: 'ARTICLES',
PODCAST: 'PODCAST'
};
И переписываем App.js:
import {MODES} from './constants';
const initialState = {
mode: MODES.ARTICLES
};
Отлично, состояние есть, пора передать его в компоненту футера. Давайте ещё раз посмотрим наш ./components/AppFooter.js:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
const AppFooter = () => (
<Footer>
<FooterTab>
<Button active>
<Text>Статьи</Text>
</Button>
<Button>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);
export default AppFooter;
Как мы видим, состояние переключателя определяется с помощью свойства active у компоненты Button. Прокинем до Button текущее состояние приложения. Делается это не сложно, основную подкапотную работу берёт на себя компонент Provider, который мы подключили ранее. Остаётся только взять из него текущее состояние и положить в свойcтва (props) компоненты AppFooter. Первым делом, модифицируем наш AppFooter так, чтобы состоянием кнопок можно было управлять, передавая mode через props:
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
import {MODES} from "../constants";
const AppFooter = ({mode = MODES.ARTICLES}) => (
<Footer>
<FooterTab>
<Button active={mode === MODES.ARTICLES}>
<Text>Статьи</Text>
</Button>
<Button active={mode === MODES.PODCAST}>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);
export default AppFooter;
Теперь приступим к созданию контейнера. Создадим файл ./containers/AppFooterContainer.js.
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {MODES} from "../constants";
const AppFooterContainer = () => (
<AppFooter mode={MODES.ARTICLES} />
);
export default AppFooterContainer;
И подключим контейнер AppFooterContainer в App.js вместо компоненты AppFooter. Пока наш контейнер ничем не отличается от компоненты, но всё изменится как только мы подключим его к состоянию приложения. Сделаем это!
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {connect} from 'react-redux';
const mapStateToProps = (state) => ({
mode: state.mode
});
const AppFooterContainer = ({mode}) => (
<AppFooter mode={mode} />
);
export default connect(
mapStateToProps
)(AppFooterContainer);
Весьма функционально! Все функции стали чистыми. Что тут происходит? Мы подключаем наш контейнер к состоянию с помощью функции connect и соединяем его props с содержимым глобального state с помощью функции mapStateToProps. Очень чисто и красиво.
Итак, мы научились распространять данные сверху вниз. Теперь нужно научиться изменять наш глобальный state снизу вверх. Для порождения событий о необходимости изменения глобального состояния предназначены actions. Давайте создадим action, возникающий при событии нажатия на кнопку.
Создадим файл ./actions/index.js:
import {
SET_MODE
} from './actionTypes';
export const setMode = (mode) => ({type: SET_MODE, mode});
И файл ./actions/actionTypes, в котором будем хранить константы с именами экшенов:
export const SET_MODE = 'SET_MODE';
Экшен создаёт объект с именем события и набором данных, которые это событие сопровождают, и ничего больше. Теперь научимся порождать это событие. Возвращаемся в контейнер AppFooterContainer и подключаем функцию mapDispatchToProps которая подключит диспатчеры событий к props контейнера.
import React from 'react';
import AppFooter from '../components/AppFooter.js';
import {connect} from 'react-redux';
import {setMode} from '../actions';
const mapStateToProps = (state) => ({
mode: state.mode
});
const mapDispatchToProps = (dispatch) => ({
setMode(mode) {
dispatch(setMode(mode));
}
});
const AppFooterContainer = ({mode, setMode}) => (
<AppFooter mode={mode} setMode={setMode} />
);
export default connect(
mapStateToProps,
mapDispatchToProps
)(AppFooterContainer);
Отлично у нас есть функция, порождающая событие SET_MODE и мы прокинули её до компонента AppFooter. Осталось две проблемы:
Эту функцию никто не вызывает
Никто не слушает событие
Разберёмся с первой проблемой. Идём в компонент AppFooter и подключаем вызов функции setMode.
import React from 'react';
import {Footer, FooterTab, Button, Text} from 'native-base';
import {MODES} from "../constants";
const AppFooter = ({mode = MODES.ARTICLES, setMode = () => {}}) => (
<Footer>
<FooterTab>
<Button
active={mode === MODES.ARTICLES}
onPress={
() => setMode(MODES.ARTICLES)}>
<Text>Статьи</Text>
</Button>
<Button
active={mode === MODES.PODCAST}
onPress={
() => setMode(MODES.PODCAST)}>
<Text>Подкасты</Text>
</Button>
</FooterTab>
</Footer>
);
export default AppFooter;
Теперь по нажатии на кнопку будет порождаться событие SET_MODE. Осталось научиться изменять глобальный state по его возникновению. Идём в ранее созданный ./reducers/index.js и создаём редьюсер для этого события:
import {
SET_MODE
} from '../actions/actionTypes';
export default (state = [], action) => {
switch (action.type) {
case SET_MODE: {
return Object.assign({}, state, {
mode: action.mode
});
}
default:
return state
}
};
Шикарно! Теперь клик по кнопке порождает событие, изменяющее глобальное состояние, а футер, получив эти изменения, перерисовывает кнопки.
Оригинал статьи
Правда, неимоверно просто? Страшно представить, сколько программистов умирают от старости на проектах React Native и сколько за все это безобразие платится денег. Результатом всего этого является небольшой пример, чуть сложнее, чем «Hello World».
Как-то после концертной программы альбома "…And Justice for All" в 1988 году лидер Metallica Джеймс Хетфилд сказал — «Такое го… но живьем играть невозможно». Так вот, после того, как я написал пример кода на React Native, я стал солидарен с Джеймсом — такое го… но живьем писать невозможно!
А вот как то же самое делается с помощью фреймворка Kivy:
from kivy.app import App
from kivy.factory import Factory
from kivy.lang import Builder
Builder.load_string("""
<MyButton@Button>:
background_down: 'button_down.png'
background_normal: 'button_normal.png'
color: 0, 0, 0, 1
bold: True
on_press:
self.parent.parent.ids.textEdit.text = self.text;
self.color = [.10980392156862745, .5372549019607843, .996078431372549, 1]
on_release: self.color = [0, 0, 0, 1]
<MyActivity@BoxLayout>:
orientation: 'vertical'
TextInput:
id: textEdit
BoxLayout:
size_hint_y: None
height: dp(45)
MyButton:
text: 'Статьи'
MyButton:
text: 'Подкасты'
""")
class Program(App):
def build(self):
my_activity = Factory.MyActivity()
return my_activity
Program().run()
Это настолько просто, что здесь даже комментарии излишни.
Да, возможно, вы не знали об этом, но все это написано на Kivy:
vimeo.com/29348760
vimeo.com/206290310
vimeo.com/25680681
www.youtube.com/watch?v=u4NRu7mBXtA
www.youtube.com/watch?v=9rk9OQLSoJw
www.youtube.com/watch?v=aa9LXpg_gd0
www.youtube.com/watch?v=FhRXAD8-UkE
www.youtube.com/watch?v=GJ3f88ebDqc&t=111s
www.youtube.com/watch?v=D_M1I9GvpYs
www.youtube.com/watch?v=VotPQafL7Nw
В заключении привожу видео работы приложения «Цитаты Святых»:
Пишите в комментариях, какие бы вы статьи о Kivy хотели видеть на страницах Хабра. По возможности все пожелания будут реализованы. До новых встреч, дрзья!
Автор: Юрий