14 марта 2012 в 14:36

Android SDK: боремся с ограничением размера памяти для картинок из песочницы

В графическом приложении для рисования используется SurfaceView и пара Bitmap размером с экран (например, я хочу изобразить плавное листание страниц книги).

На многих устройствах с большим разрешением экрана приложение падает c ошибкой
AndroidRuntime: java.lang.OutOfMemoryError: bitmap size exceeds VM budget

Проблема в том, что память для Bitmap, а также для SurfaceView резервируется из общей кучи процесса. Лимит размера кучи — невелик, как правило немногим больше 10Мб. И задается этот лимит при сборке системы.

Попытки улучшить ситуацию урезанием формата пикселя с 32 бит до 16 не слишком помогают. Проблема просто вылезает позже — например, при открытии окна поверх SurfaceView (видимо, при этом создается еще один Bitmap размером с экран).

Ограничение размера графических буферов программы в 3-4 экрана — это до обидного мало! Попробуем исправить такую несправедливость.

На самом деле, большая часть функциональности Bitmap реализована в нативном коде (JNI), и буфер выделяется не в Java Heap, а с помощью обычного сишного malloc. Почему же на него накладываются ограничения общего размера heap?

Курим исходники.

Оказывается, каждое выделение памяти для Bitmap регистрируется в dalvik.system.VMRuntime c момощью методов trackExternalAllocation / trackExternalFree. Именно метод trackExternalAllocation бросает исключение при попытке выделения памяти сверх лимита.

Что если попытаться обмануть тупого робота? После размещения картинки сказать, что внешняя память, которую только что заняли, уже освобождена. А перед освобождением картинки — имитировать что соответствующий размер памяти только что занят.

Осталось преодолеть небольшую проблему — методы trackExternalAllocation и trackExternalFree не видны. Придется вызывать их «хакерскими» методами — через Reflection.

Пробуем реализовать эту идею.
Создаем пустой проект Android Application.

Для удобства доступ к VMRuntime реализуем в отдельном классе.

	static class VMRuntimeHack {
		private Object runtime = null;
		private Method trackAllocation = null;
		private Method trackFree = null;
		
		public boolean trackAlloc(long size) {
			if (runtime == null)
				return false;
			try {
				Object res = trackAllocation.invoke(runtime, Long.valueOf(size));
				return (res instanceof Boolean) ? (Boolean)res : true;
			} catch (IllegalArgumentException e) {
				return false;
			} catch (IllegalAccessException e) {
				return false;
			} catch (InvocationTargetException e) {
				return false;
			}
		}

		public boolean trackFree(long size) {
			if (runtime == null)
				return false;
			try {
				Object res = trackFree.invoke(runtime, Long.valueOf(size));
				return (res instanceof Boolean) ? (Boolean)res : true;
			} catch (IllegalArgumentException e) {
				return false;
			} catch (IllegalAccessException e) {
				return false;
			} catch (InvocationTargetException e) {
				return false;
			}
		}
		public VMRuntimeHack() {
			boolean success = false;
			try {
				Class cl = Class.forName("dalvik.system.VMRuntime");
				Method getRt = cl.getMethod("getRuntime", new Class[0]);
				runtime = getRt.invoke(null, new Object[0]);
				trackAllocation = cl.getMethod("trackExternalAllocation", new Class[] {long.class});
				trackFree = cl.getMethod("trackExternalFree", new Class[] {long.class});
				success = true;
			} catch (ClassNotFoundException e) {
			} catch (SecurityException e) {
			} catch (NoSuchMethodException e) {
			} catch (IllegalArgumentException e) {
			} catch (IllegalAccessException e) {
			} catch (InvocationTargetException e) {
			}
			if (!success) {
				Log.i(TAG, "VMRuntime hack does not work!");
				runtime = null;
				trackAllocation = null;
				trackFree = null;
			}
		}
	}

	private static final VMRuntimeHack runtime = new VMRuntimeHack();


Создание/освобождение Bitmap оформим в виде отдельного класса — фабрики.
Здесь же будем запоминать, для каких из битмапов мы поправили информацию о занятой памяти — чтобы не забыть вернуть при освобождении.

    static class BitmapFactory {
    	
    	public BitmapFactory(boolean useHack) {
    		this.useHack = useHack;
    	}
    	
	// создать картинку
    	public Bitmap alloc(int dx, int dy) {
			Bitmap bmp = Bitmap.createBitmap(dx, dy, Bitmap.Config.RGB_565);
			if (useHack) {
				runtime.trackFree(bmp.getRowBytes() * bmp.getHeight());
				hackedBitmaps.add(bmp);
			}
			allocatedBitmaps.add(bmp);
			return bmp;
    	}

	// освободить картинку
    	public void free(Bitmap bmp) {
   			bmp.recycle();
			if (hackedBitmaps.contains(bmp)) {
				runtime.trackAlloc(bmp.getRowBytes() * bmp.getHeight());
				hackedBitmaps.remove(bmp);
			}
			allocatedBitmaps.remove(bmp);
    	}

	// освоболить все картинки (удобно для тестирования)    	
    	public void freeAll() {
    		for (Bitmap bmp : new LinkedList<Bitmap>(allocatedBitmaps))
    			free(bmp);
    	}

    	private final boolean useHack;
    	
    	private Set<Bitmap> allocatedBitmaps = new HashSet<Bitmap>(); 
    	private Set<Bitmap> hackedBitmaps = new HashSet<Bitmap>(); 
    }


Теперь напишем тест, проверяющий, работает ли данный метод. Метод testAllocation() будет пытаться создать максимальное количество мегабайтных картинок (пока не вылетит OutOfMemory или не будет достигнут указанный предел). Флажком useHack задаем запуск теста с хаком или без. Метод возвращает объем, который удалось занять под картинки.

    public int testAllocation(boolean useHack, int maxAlloc) {
    	System.gc();
    	BitmapFactory factory = new BitmapFactory(useHack);
    	int allocated = 0;
    	// AndroidRuntime: java.lang.OutOfMemoryError: bitmap size exceeds VM budget
    	while (allocated < maxAlloc) {
    		try {
	    		Bitmap bmp = factory.alloc(1024, 512);
	    		allocated += bmp.getRowBytes() * bmp.getHeight();
    			Log.i(TAG, "Bitmap bytes allocated " + allocated);
    		} catch (OutOfMemoryError e) {
    			Log.e(TAG, "Exception while allocation of bitmap, total size = " + allocated, e);
    			break;
    		}
    	}
    	factory.freeAll();
    	return allocated;
    }


Вызовем тест в Activity.onCreate() — с применением хака и без него.
Результаты покажем на экране и в логе. (Layout «main» нужно поправить — добавить к TextView ID, по которому будем менять текст).

    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        // perform test
        int allocatedNormally = testAllocation(false, 48 * MB);
        int allocatedWithHack = testAllocation(true, 48 * MB);
		String msg = "normally: " + (allocatedNormally / MB) + " MB allocated " +
				"\nwith hack: " + (allocatedWithHack / MB) + " MB allocated";
		Log.i(TAG, msg);
        
        // display results
		LayoutInflater inflater = LayoutInflater.from(this);
		View main = (View)inflater.inflate(R.layout.main, null);
		TextView text = (TextView)main.findViewById(R.id.text);
		text.setText(msg);
        setContentView(main);
    }


Итак, запускаем и видим результат:
03-07 09:43:37.233: I/bmphack(17873): normally: 10 MB allocated
03-07 09:43:37.233: I/bmphack(17873): with hack: 48 MB allocated


Без хака нам удалось создать всего 10 картинок по 1 мегабайту. С хаком — максимальное количество, которое мы указали (48 мегабайт).
С большими размерами тест все же подвисал на моем симуляторе — после ~58Mb.

Надеюсь, кому-то эта статья окажется полезной.
+27
6520
180
Buggins 48,5

комментарии (26)

0
vadia, #
Для меня это очень болезненная тема (работа с Bitmap) в Android! В своем приложении я давно с ней борюсь. Сейчас я полностью отказался от «bmp.recycle()» и хранения жестких ссылок на Bitmap, только WeakReference.
Хотелось бы узнать Вы пробовали данный метод на «боевых» приложениях в маркете?
0
Buggins, #
Да.
Cool Reader — 1.7млн загрузок, 800000 активных инсталляций.

+2
vadia, #
спасибо за статью и за приложение :-)
0
dzigoro, #
Скажите пожалуйста, как это сказывается на производительности системы? Как затрагивает другие приложения? Будет ли высвобождена эта память?
0
Pingwin32, #
Интересный момент, как эта память высвободится при закрытии приложения: штатном, аварийном?
0
vadia, #
Интересный момент, как приложение будет вести себя под различными версиями android.
0
Buggins, #
По крайней мере, работает на Android с 1.5 по 4.0
Проблем не замечено
0
SmartT, #
Хак работает на всех версиях, кроме 3.0 и выше, протестировал. На 3.0 битмапы внесли в память программы, наверное из-за этого.
0
Buggins, #
Спасибо, не знал.

Надо что-то придумать и для 3.0+
10Mb это ну очень мало…
0
akira, #
Для 4+, с 3+ работает нормально.
0
Buggins, #
Если бы нормально работало, с хаком должно 48Мб взять.
Хак не работает. Просто, видимо, прошивка для симулятора собрана с бОльшим размером хипа на приложение.
+1
SmartT, #
на девайсе 4.0.3
normally: 38 MB allocated
with hack: 38 MB allocated

на эмуляторе 4.0.3
normally: 10 MB allocated
with hack: 10 MB allocated

на эмуляторе 3.0
normally: 41 MB allocated
with hack: 41 MB allocated

на эмуляторе 2.3.3
normally: 9 MB allocated
with hack: 48 MB allocated
0
dryganets, #
Начиная с 3.0 ,bitmaps выделяются в одном и том же хипе с стандартными объектами
до этого был отдельный.

Хак не должен работать чисто технически
0
Buggins, #
При закрытии приложения память освободится автоматически — вместе с закрытием виртуальной машины.
0
Buggins, #
Полностью эквивалентно тому, что память заняла JNI-библиотека.
Если съесть слишком много памяти, могут быть затронуты другие работающие приложения.
Например, будут закрыты те фоновые приложения, которые в другом случае остались бы висеть в фоне.
0
akira, #
Я так понимаю, что данные нароботки использованы в CoolReader?
Если да, то можно сказать, что технология работает отлично, потому что падений CoolReader я не замечал.
Правда были проблемы с отображением страниц (черный цвет) в Nook Touch, так что интересно узнать.
0
Buggins, #
Да, только так удалось заставить CoolReader работать на некоторых устройствах с большим экраном.
0
jusalex, #
Для решения аналогичной проблемы разместил большие картинки в assets/img и работаю с ними напрямую через BitmapFactory.decodeStream().
Далее проверяю и сравниваю размеры изображения и размеры экрана. Если первые больше вторых, уменьшаю картинку:
Bitmap.createScaledBitmap()

Такой подход избавляет от зоопарка картинок для разных размеров экранов и не забивает память. По окончанию использования, высвобождаю память.
0
Buggins, #
Архив с исходным кодом примера можно скачать здесь
0
grishkaa, #
Спасибо за способ — тоже очень актуальна эта проблема, обязательно попробую у себя.

Кстати, через JNI можно без каких-либо последствий для системы занять примерно 13% от всей памяти устройства — после достижения этого предела начинают убиваться фоновые процессы.
0
vitalikis, #
Спасибо, очень полезная статья.
+1
djvu, #
Словил похожие грабли при попытке создать полноэкранную анимацию из нескольких десятков фреймов (размером ~640*480). Падало с нехваткой памяти на 11-м кадре.
В итоге, решил проблему загрузив картинки в качестве текстур в OpenGL, теперь 40 кадров поместилось в память отлично.
0
K1N6, #
Можете по подробнее расписать ваше решение?
Как загружать, как использовать?
0
djvu, #
Это тема для целой статьи, если в двух словах, то:
В OpenGL окружении создается полигон с текстурой, в память OpenGl грузим все фреймы анимации в виде текстур (256*256 или 512*512, размер должен быть обязательно кратный степени 2) соотношение стороно задается не размерами текстуры а размерами полигона, далее в зависимости от необходимой скорости анимации, переключаем текстуры на полигоне через заданные промежутки времени.
Перед выходом со страницы или смены анимации на другую, очищаем GL память от прошлого набора текстур.
0
K1N6, #
Спасибо! А где можно об этом почитать или посмотреть примеры?
0
djvu, #
4 лучших урока с азами по OpenGl для Android из тех что я видел
www.jayway.com/2009/12/03/opengl-es-tutorial-for-android-part-i/

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