Быстрая десериализация действительно больших JSON-ответов

Под катом находится небольшое, но полезное описание того, как быстро и просто превратить пришедший JSON-ответ в набор объектов. Никакого ручного парсинга. А если вы сталкивались с OutOfMemory проблемой на старых смартфонах – и для этого есть решение, поддерживающее Android 2.X версий.

Кроме того, под катом будет ссылка на репозиторий на гитхабе с примером кода. А вот картинок не будет, зато найдётся место для небольшой таблички.

Итак, на текущем проекте у меня возникла необходимость парсить ответ сервиса, состоящий из пачки вложенных друг в друга объектов, внутри которых могли быть объекты, внутри которых… Данные были в формате JSON, кроме того, было использовано gzip-сжатие сервером, всё-таки разница в размере переданных данных была значительна (4 мегабайте против 300 килобайт в сжатом виде – для мобильной связи это не шутка).

Как человеку ленивому, парсить руками каждое поле и объект мне было совсем не с руки… Таким образом, была задействована библиотека Gson, судя по тестом – быстрейший десериализатор из формата JSON. Ну а теперь, приступим, и начнём сразу с кода. Для простоты весь вывод ведём в консоль, что бы не думать о вьюшках и прочем.

Вот так выглядят объекты, которые прилетают нам из сети:

public class HumorItem {
    public String text;
    public String url;
}
public class HumorItems {
    List<HumorItem> Items; //тут может быть больше списков, и не только списки, для примера упростим.
}

А вот так – код, который его скачивает и десериализует.
Первый вариант кода
public class LoadData extends AsyncTask<Void, Void, Void> {

	String _url="";

	public LoadData(String url){
		_url=url;
	}

	@Override
	protected Void doInBackground(Void... voids) {
		try {
			//скачивание данных
			HttpClient httpclient = new DefaultHttpClient();
			HttpPost httppost = new HttpPost(_url);
			HttpResponse response = httpclient.execute(httppost);
			HttpEntity httpEntity=response.getEntity();
			InputStream stream = AndroidHttpClient.getUngzippedContent(httpEntity); //для скачивания gzip-нутых данных

			BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(stream));
			StringBuilder responseBuilder= new StringBuilder();
			char[] buff = new char[1024*512];
			int read;
			while((read = bufferedReader.read(buff)) != -1) {
				responseBuilder.append(buff, 0, read) ;
				Log.d("скачано " + PrepareSize(responseBuilder.length()));
			}

			//парсинг полученных данных
			HumorItems list= Gson.fromJson(responseBuilder.toString(),HumorItems.class);

			//тестовый вывод
			for (HumorItem message:list.Items){
				Log.d("Текст: "+message.text);
				Log.d("Ссылка: "+message.url);
				Log.d("-------------------");
			}

			Log.d("ВСЕГО СКАЧАНО "+list.Items.size());

		} catch (IOException e) {
			e.printStackTrace();
			Log.e("ошибка "+e.getMessage());
		}

		return null;
	}
}

Обёртка вокруг Log и метод для оформления размера файла
public class Log {
    public static final String TAG="hhh";

    public static void d(String text){
        android.util.Log.d(TAG,text);
    }

    public static void e(String text){
        android.util.Log.e(TAG,text);
    }
}

    public String PrepareSize(long size){
        if (size<1024){
            return size+" б.";
        }else
        {
            return size/1024+" кб.";
        }
    }

И это решение отлично работало! До поры до времени. Ответ для одной из комбинации параметров весил порядка 8 мегабайт. При тестировании на части телефонов – программа падала, где на пятом скачанном мегабайте, где на третьем.

Гугл подсказал сначала простое решение — выставить largeHeap в фале AndroidManifest.

<application [...] android:largeHeap="true">

Этот параметр позволяет приложению выделить под себя больше оперативной памяти. Вариант конечно ленивый и простой, но телефонами на Android ниже 3й версии не поддерживается. Да и в целом подход какой-то пораженческий – “зачем оптимизировать, если можно купить ещё железа?”

Далее, после нескольких попыток был выбран такой, простой вариант:
  • Не наполняем файлом переменную, нет – скачиваем данные непосредственно на флешку (ну или внутреннюю память, что под руку подвернётся).
  • Натравливаем Gson на этот файл. Проблема в парсинге и занимаемой файлом памяти не возникает.

Сказано-сделано:
Второй вариант кода - с временным файлом
public class LoadBigDataTmpFile extends AsyncTask<Void, Void, Void> {

        String _url="";
        File cache_dir;

        public LoadBigDataTmpFile(String url){
            _url=url;
            cache_dir = getExternalCacheDir();
        }

        @Override
        protected Void doInBackground(Void... voids) {
            try {
                //скачивание данных
                HttpClient httpclient = new DefaultHttpClient();
                HttpPost httppost = new HttpPost(_url);
                HttpResponse response = httpclient.execute(httppost);	
                HttpEntity httpEntity=response.getEntity();
                InputStream stream = AndroidHttpClient.getUngzippedContent(httpEntity);

                //нечто новое - открываем временный файл для записи
                BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(stream));
                File file = new File(cache_dir, "temp_json_new.json");
                if (file.exists()){ //если таковой уже есть - удаляем и создаём новый
                    file.delete();
                }
                file.createNewFile();
                FileOutputStream fileOutputStream=new FileOutputStream(file,true);
                BufferedWriter bufferedWriter=new BufferedWriter(new OutputStreamWriter(fileOutputStream));

                char[] buff = new char[1024*1024];
                int read;
                long FullSize=0;
                while((read = bufferedReader.read(buff)) != -1) {
                    bufferedWriter.write(buff,0,read);	//запись в файл
                    FullSize+=read;
                    Log.d("скачано " + PrepareSize(FullSize));
                }
                bufferedWriter.flush();
                fileOutputStream.close();

                //парсинг из файла
                Log.d("начали парсинг...");
                FileInputStream fileInputStream=new FileInputStream(file);
                InputStreamReader reader = new InputStreamReader(fileInputStream);
                HumorItems list= Gson.fromJson(reader,HumorItems.class);
                Log.d("закончили парсинг.");

                /тестовый вывод
                for (HumorItem message:list.Items){
                                Log.d("Текст: "+message.text);
                                Log.d("Ссылка: "+message.url);
                                Log.d("-------------------");
                }
                Log.d("ВСЕГО СКАЧАНО "+list.Items.size());

            } catch (IOException e) {
                e.printStackTrace();
                Log.e("ошибка "+e.getMessage());
            }

            return null;
        }
    }

Вот и всего-то. Код проверен в боевых условиях, работает стабильно на ура. Впрочем, можно сделать ещё проще и обойтись без временного файла.
Третий вариант кода - без временного файла
public class LoadBigData extends AsyncTask<Void, Void, Void> {

	String _url="";

	public LoadBigData(String url){
		_url=url;
	}

	@Override
	protected Void doInBackground(Void... voids) {
		try {
			//скачивание данных
			HttpClient httpclient = new DefaultHttpClient();
			HttpPost httppost = new HttpPost(_url);
			HttpResponse response = httpclient.execute(httppost);
			HttpEntity httpEntity=response.getEntity();
			InputStream stream = AndroidHttpClient.getUngzippedContent(httpEntity);

			//открывам потом на чтение данных
			BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(stream));
			//и сразу направляем его в десериализатор
			InputStreamReader reader = new InputStreamReader(stream);
			HumorItems list= Gson.fromJson(reader,HumorItems.class);

			//тестовый вывод
			for (HumorItem message:list.Items){
				Log.d("Текст: "+message.text);
				Log.d("Ссылка: "+message.url);
				Log.d("-------------------");
			}
			Log.d("ВСЕГО СКАЧАНО "+list.Items.size());

		} catch (IOException e) {
			e.printStackTrace();
			Log.e("ошибка "+e.getMessage());
		}

		return null;
	}
}

Минус – не удастся контролировать процесс скачивания (прервать его адекватным способом), а так же – неизвестно, сколько уже скачано данных. Красивый прогресс-бар не нарисуешь.

Есть ещё один вариант, приведённый в документации, позволяющий последовательно вытаскивать объекты и тут же их обрабатывать, но с ним проблематично работать, если у вас объект разных массивов объектов, а не просто массив однотипных. Впрочем, если у вас есть красивое решение – с удовольствием увижу его в комментариях, и обязательно включу в статью в update’е!

В качестве бонуса – немного статистики.
Размер файла Число объектов внутри Время десериализации на эмуляторе Время десериализации на Highscreen Boost
5.79 МБ 4000 35 секунд 2 секунды
13.3 МБ 9000 1 минута 11 секунд 5 секунд

Пример использования – на гитхабе, тестовые файлы там же.
Ссылка на библиотеку Gson.

Если кому будет интересна тема разработки под андроид, то впереди как минимум посты о push-нотификациях (серверная и клиентская сторона – на хабре были статьи на эту тему, но они все несколько устарели), о работе с базой и иные на тему разработки под Android.

Update. На гитхабе показали решение проблемы «Минус – не удастся контролировать процесс скачивания (прервать его адекватным способом), а так же – неизвестно, сколько уже скачано данных. Красивый прогресс-бар не нарисуешь.». Подробности — в последнем коммите в репозитории.
  • +13
  • 18,7k
  • 8
Поделиться публикацией

Комментарии 8

    +2
    Спасибо за статью! Интересно читать опыт других людей.

    Мы для работы с json используем AndroidAnnotations Rest API (для получения с сервиса json) + Jackson (парсинг). Тоже работает отлично, падений по памяти замечено не было. Ну это, так сказать, может кому-то пригодится наш опыт :)
      +5
      Захотелось придраться :)

      Во-первых, грузить столько данных за раз, тем более на мобильное устройство, ну это у вас наверное странное странное апи. Как правило, доступ к большому количеству объектов делается со смещением от последнего загруженого (или аналогично), ну ок.

      Во-вторых, раз уж вы грузите такие объемы, пихать их все распаршенными в память в любом случае неправильно, т.к. даже если вы сначала запишите весь ответ от сервера в файл или распарсите его целиком налету, то вы же все равно можете выпасть с OutOfMemoryError после его парсинга, решение с файлом и потоковым парсингом всех записей просто сократило вам потребление памяти ~ в 2 раза. Так что, смею заявить, что все предложенные способы упадут с OutOfMemoryError на еще более больших объемах данных.

      Как бы сделал я (надеюсь не придется с таким апи работать), я бы последовательно парсил элементы прямо из InputStream от сервера и писал их в БД, зачем? Чтобы минимизировать расходы по памяти, т.к. InputStream освобождается при чтении, а все элементы в памяти мы не храним, пишем все в БД. Соответственно, потом надо будет просто извлекать их уже из БД при отображении в списке.

      Естественно, писать в БД можно не прямо последовательно, а накапливая небольшой буфер в памяти. Так же и с чтением из БД при отображении в списке, можно подгружать следующие паки данных из БД пока пользователь не докрутил список до них, можно держать небольшой кеш данных в SoftHashMap чтобы не дергать БД постоянно. Вот как то так :)
        +1
        В целом согласен (правда я замерял, разница не в 2 раза, а даже больше, и тем не менее).

        Но, если мне нужно скачать за один раз не пачку однотипных объектов, а 5-6 разных типов? Сделать 5 отдельных запросов, а далее парсить каждый… я пробовал, получается медленнее и дольше.

        Это первая загрузка данных, последующие тянут только диффы. Так что не всё так страшно. Устанавливая 2Гис тоже первый раз скачиваешь карту, но работа в оффлайне того стоит. ;-)
        0
        В свое время решил данную проблему с помощью
         JsonReader reader = new JsonReader(new InputStreamReader(is, "UTF-8"));
        

        это аналог SAX парсера для XML, только в отношении Json.
        Определяешь какие секции тебе нужны и итеративно их пробегаешь и парсишь.
        Метод геморойный, так как это почти ручной парсинг, но самый лучший по использованию памяти,
        так как сам определяешь что оставить, а что взять в обработку.
        Совместно с описанным выше импортом в базу, самое оптимальное решение.
          0
          Да, я об этом методе упомянул. На него есть резон переходить, если текущий не срабатывает, скажем разница в скорости будет большой или в потреблении памяти. Так что кому-нибудь пригодится, безусловно)
          +1
          Минус – не удастся контролировать процесс скачивания (прервать его адекватным способом), а так же – неизвестно, сколько уже скачано данных. Красивый прогресс-бар не нарисуешь.

          Ошиаетесь. Вполне себе стандартный трюк — написать врапер стрима который бы подсчитывал сколько байт через него прошло. Писал и использовал на практике. Начните копать с FilterInputStream, но можно и без него, главное понимание стримов вообще. И прервать его можно вполне адекватно если знать матчасть.
            +1
            Как оказалось Swing уже содержит образец тыц
              0
              Благодарю!

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое