Pull to refresh

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

Reading time 5 min
Views 16K
В графическом приложении для рисования используется 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.

Надеюсь, кому-то эта статья окажется полезной.
Tags:
Hubs:
+27
Comments 26
Comments Comments 26

Articles